Repository navigation
Description
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-latestCI 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), readingstdout/stderrvia
new Response(proc.stdout).text(), thenawait proc.exited:
runs 29117958656, 29117963187, 29117967629. - This is happening moments after a different async
Bun.spawn()call in the same process
already ran an equivalentsshlogin 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:
- The real
sshcall completes a full successful SSH handshake + pubkey auth + remote
exec, not an instant connection-refused. - It runs shortly after a different async
Bun.spawn()SSH login already succeeded in the
same process. - 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 unpinnedoven-sh/[email protected]— latest
stable at time of testing (released 2026-07-08) windows-latestGitHub Actions runner (Windows Server 2025)- Win32-OpenSSH
ssh.exe(preinstalled on the runner image)
Activity
robobun commentedon Jul 10, 2026
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.
Fix spawnSync timeout firing immediately on Windows after the loop si…
acosmi-fushihua commentedon Oct 17, 2026
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: 5000and a child that never writes, time from the call untilETIMEDOUT, as a function of how long the process had been idle (nospawnSync) before the call:spawnSyncETIMEDOUTi.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.exefixture withtimeout: 20_000dying withETIMEDOUTafter 12 s, deep inside a long single-processbun testrun.For the record,
compareshows 282b94c (#33935) is contained inbun-v1.4.0and not inbun-v1.3.14, and the isolated loop (#24436) first shipped inbun-v1.3.2, so the affected releases are 1.3.2–1.3.14.Probe (run with
bun probe.tson Windows):fix: end-to-end audit fixes across all harnesses