Skip to content

Bun.spawnSync({ timeout }) returns exitCode: null with empty stdout/stderr for a real ssh.exe child on Windows — not reproduced with a synthetic closed-port case #33932

Closed
Art-of-Technology/collab
#563

Description

@mobileskyfi

Bug

On Windows CI (windows-latest, Bun 1.3.14), Bun.spawnSync(cmd, { stdout: "pipe", stderr: "pipe", timeout }) returned exitCode: null, signalCode unset, and empty stdout and stderr, ~350–450ms after the child process should have started — nowhere near the timeout value (20s), for a real ssh.exe batch-login child process that authenticates successfully in every other invocation. The equivalent async Bun.spawn() + a manual setTimeout-based .kill() for the exact same command, on the same runner, in the same job, never failed.

Real-world context (reproduced 3/3, fixed 3/3)

Full write-up with logs: tikoci/quickchr#92

  • Failing call (test/integration/provisioning.test.ts, before fix):
    const login = Bun.spawnSync(
      ["ssh", "-F", emptyConfig, "-o", "StrictHostKeyChecking=no",
       "-o", `UserKnownHostsFile=${SSH_NULL_DEVICE}`, "-o", "PasswordAuthentication=no",
       "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10",
       "-i", privateKeyPath, `${user}@127.0.0.1`, "-p", String(sshPort),
       ':put "managed-key-login-ok"'],
      { stdout: "pipe", stderr: "pipe", timeout: 20_000 },
    );
    // login.exitCode === null, login.signalCode unset, stdout/stderr both "" — 3/3 runs
  • Reproduced on 3 separate windows-latest CI runs (GitHub Actions), always the identical
    signature, always ~400ms after the call, always fully empty output on both streams:
    runs 29116468133, 29116483756, 29116488086.
  • Fixed 3/3 by switching the same command and options (minus timeout) to async
    Bun.spawn() + setTimeout(() => proc.kill(), 20_000), reading stdout/stderr via
    new Response(proc.stdout).text(), then await proc.exited:
    runs 29117958656, 29117963187, 29117967629.
  • This is happening moments after a different async Bun.spawn() call in the same process
    already ran an equivalent ssh login successfully, and while an unrelated QEMU child
    process is alive and active in the same parent process — flagging in case that's relevant
    (concurrent child-process/handle state), since I could not isolate it further (see below).

What I ruled out (minimal repro attempt — did NOT reproduce)

Wanting a standalone case before filing, I isolated Bun.spawnSync({ timeout }) from
QEMU/SSH-session-specific setup entirely and ran it on the same windows-latest runner type,
same Bun version, same job:

// A: fast command, no timeout — control
Bun.spawnSync(["cmd", "/c", "echo repro-ok"], { stdout: "pipe", stderr: "pipe" });
// exitCode=0, stdout="repro-ok"

// B: fast command, WITH timeout
Bun.spawnSync(["cmd", "/c", "echo repro-ok"], { stdout: "pipe", stderr: "pipe", timeout: 5_000 });
// elapsedMs=12, exitCode=0, stdout="repro-ok"

// C: ~2s command, WITH timeout (well under budget)
Bun.spawnSync(["cmd", "/c", "ping -n 3 127.0.0.1 >nul & echo repro-ok"], { stdout: "pipe", stderr: "pipe", timeout: 10_000 });
// elapsedMs=2056, exitCode=0, stdout="repro-ok"

// D: real ssh.exe against a closed local port, WITH timeout
Bun.spawnSync(
  ["ssh", "-F", emptyConfigPath, "-o", "StrictHostKeyChecking=no", "-o", "PasswordAuthentication=no",
   "-o", "BatchMode=yes", "-o", "ConnectTimeout=3", "[email protected]", "-p", "1", "true"],
  { stdout: "pipe", stderr: "pipe", timeout: 8_000 },
);
// elapsedMs=2789, exitCode=255, stderr="ssh: connect to host 127.0.0.1 port 1: Connection refused"

All four passed normally — including a real ssh.exe network-connecting child (case D),
which failed fast with a correct exit code and real stderr, not the null/empty pattern.
Full source: https://github.com/tikoci/quickchr/blob/lab/spawnsync-timeout-windows-repro/test/integration/lab-spawnsync-timeout-repro.test.ts
— run: https://github.com/tikoci/quickchr/actions/runs/29120354873/job/86453809225

So this isn't "spawnSync+timeout is broken on Windows" in general — case D shows a real
network-connecting ssh.exe under timeout works fine when it fails fast at TCP-connect.
The real failure differs in at least these ways I wasn't able to isolate:

  1. The real ssh call completes a full successful SSH handshake + pubkey auth + remote
    exec
    , not an instant connection-refused.
  2. It runs shortly after a different async Bun.spawn() SSH login already succeeded in the
    same process.
  3. A separate QEMU child process is alive and actively doing I/O in the same parent process
    throughout.

I don't have a Windows box to dig further locally, and building a closer repro (e.g. a local
test sshd, or reproducing the concurrent-child-process angle) felt like more than a one-off
report should require — flagging honestly rather than asserting a cause I haven't verified.

Environment

  • Bun 1.3.14 (bun test v1.3.14 (0d9b296a)), via unpinned oven-sh/[email protected] — latest
    stable at time of testing (released 2026-07-08)
  • windows-latest GitHub Actions runner (Windows Server 2025)
  • Win32-OpenSSH ssh.exe (preinstalled on the runner image)

Activity

  1. robobun commented on Jul 10, 2026

    @robobun
    Collaborator

    Reproduced on Windows: spawnSync uses a cached per-VM libuv loop for the timeout timer, and that loop's cached clock goes stale while idle, so if more wall time passed since the previous spawnSync than the new call's timeout, the timer fires immediately (exitCode null, empty output, child killed ~ms after spawn). That is why your back-to-back minimal repro could not trigger it, while the real call after minutes of QEMU setup always did.

    Fix in progress at #33935.

  2. added a commit that references this issue on Jul 10, 2026
    282b94c
  3. acosmi-fushihua commented on Oct 17, 2026

    @acosmi-fushihua

    Data point from Bun 1.3.11 on windows-latest (GitHub Actions), for anyone still on 1.3.x.

    Same mechanism as diagnosed above. With timeout: 5000 and a child that never writes, time from the call until ETIMEDOUT, as a function of how long the process had been idle (no spawnSync) before the call:

    idle gap since previous spawnSync elapsed until ETIMEDOUT
    0 s 5,015 ms
    3 s 2,006 ms
    8 s 8 ms

    i.e. effective timeout = configured timeout − idle gap, floored at 0. The same script on macOS (Bun 1.3.11) gives 5,005 ms for all three gaps. Our first symptom was a powershell.exe fixture with timeout: 20_000 dying with ETIMEDOUT after 12 s, deep inside a long single-process bun test run.

    For the record, compare shows 282b94c (#33935) is contained in bun-v1.4.0 and not in bun-v1.3.14, and the isolated loop (#24436) first shipped in bun-v1.3.2, so the affected releases are 1.3.2–1.3.14.

    Probe (run with bun probe.ts on Windows):

    import { spawnSync } from "node:child_process";
    const TIMEOUT_MS = 5_000;
    for (const gapMs of [0, 3_000, 8_000]) {
      spawnSync(process.execPath, ["-e", ""], { windowsHide: true }); // run the cached isolated loop once
      await Bun.sleep(gapMs);                                           // main loop runs, the isolated loop does not
      const t0 = performance.now();
      const r = spawnSync(process.execPath, ["-e", "await Bun.sleep(20_000)"], { timeout: TIMEOUT_MS, windowsHide: true });
      console.log(JSON.stringify({ bun: Bun.version, gapMs, timeoutMs: TIMEOUT_MS,
        elapsedMs: Math.round(performance.now() - t0), status: r.status, signal: r.signal, error: r.error?.code ?? null }));
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions