From fe6e25e5baaebe5562589b323c6f74daeaa328af Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:58:58 +0000 Subject: [PATCH 01/10] windows: deliver subprocess exit_cb after unref() when the loop is otherwise idle On Windows, proc.unref() calls uv_unref() on the uv_process_t, which drops it from loop->active_handles. With nothing else ref'd, uv__loop_alive() is 0, so uv_run() (any mode) skips its while-body entirely and never calls uv__poll. The wait-thread still posts the exit packet to the kernel IOCP queue, but it is never dequeued and on_exit_uv never fires, so await proc.exited never resolves. Bun's condition-gated drive loops (wait_for_promise, the bun:test runner) keep calling tick_without_idle() -> us_loop_pump() -> uv_run(NOWAIT) in that state, which turns into a hard busy-spin that never makes progress. This is what makes spawn.test.ts's 'should not hang' block time out the whole file on Windows lanes (build 74453 and earlier). Force us_loop_pump() to execute exactly one non-blocking uv_run iteration by bracketing the call with an active_handles bump. uv__poll(0) then dequeues any ready IOCP completions and uv__run_timers runs due timers. UV_RUN_NOWAIT keeps the poll timeout at 0, so this never blocks. Also replaces the stale 'This is disabled on Windows' comment in PollerWindows::disable_keeping_event_loop_alive with what actually happens. --- packages/bun-usockets/src/eventing/libuv.c | 9 ++++++++ src/spawn/process.rs | 9 ++++---- test/js/bun/spawn/spawn.test.ts | 27 ++++++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index 01c3a1932372..606a6122287f 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -256,7 +256,16 @@ void us_internal_poll_set_type(struct us_poll_t *p, int poll_type) { LIBUS_SOCKET_DESCRIPTOR us_poll_fd(struct us_poll_t *p) { return p->fd; } void us_loop_pump(struct us_loop_t *loop) { + /* uv_run() skips its body entirely when uv__loop_alive() is 0, which means + * an unref'd handle's IOCP completion (e.g. a subprocess exit packet posted + * by the wait-thread) is never dequeued and its callback never fires. Bun + * calls this from outer loops (wait_for_promise, bun:test) that have their + * own "keep going" condition, so force exactly one non-blocking iteration: + * uv__poll(0) dequeues ready completions and uv__run_timers runs due + * timers. UV_RUN_NOWAIT keeps timeout at 0, so this never blocks. */ + loop->uv_loop->active_handles++; uv_run(loop->uv_loop, UV_RUN_NOWAIT); + loop->uv_loop->active_handles--; } struct us_loop_t *us_create_loop(void *hint, diff --git a/src/spawn/process.rs b/src/spawn/process.rs index a076c610e928..d5a20c2468dd 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -946,10 +946,11 @@ impl PollerWindows { } pub fn disable_keeping_event_loop_alive(&mut self, _event_loop: bun_io::EventLoopCtx) { - // This is disabled on Windows - // uv_unref() causes the onExitUV callback to *never* be called - // This breaks a lot of stuff... - // Once fixed, re-enable "should not hang after unref" test in spawn.test + // uv_unref() drops this handle from loop->active_handles. With nothing + // else ref'd, uv__loop_alive() is 0 and uv_run() skips its body, so the + // wait-thread's IOCP exit packet is never dequeued and on_exit_uv never + // fires. us_loop_pump() compensates by forcing one non-blocking + // iteration so the exit callback is still delivered. match self { PollerWindows::Uv(p) => { p.unref(); diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index 84e205cea302..166a7f49ebd0 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -756,6 +756,33 @@ describe("should not hang", () => { } }); +it("await exited resolves after unref() when nothing else is ref'd (Windows)", async () => { + // On Windows, uv_unref() on the process handle drops it from + // loop->active_handles. With nothing else ref'd, uv_run() skips its body + // and never calls uv__poll, so the wait-thread's IOCP exit packet is never + // dequeued and on_exit_uv never fires. Before the fix, the child below + // would busy-spin forever with `exited` never resolving. + await using child = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const p = Bun.spawn({ cmd: [${JSON.stringify(bunExe())}, "-e", ""], stdio: ["ignore", "ignore", "ignore"] }); + p.unref(); + await p.exited; + console.log("resolved");`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("resolved\n"); + expect(child.signalCode).toBeNull(); + expect(exitCode).toBe(0); +}); + it("#3480", async () => { { using server = Bun.serve({ From d880a3bea0dbbfb989925b1674b022a8e0a276a1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:10:22 +0000 Subject: [PATCH 02/10] test(spawn): drop the !isWindows unref() guards now that exit_cb is delivered Also switch the new test to a combined-object assertion per the review convention. --- test/js/bun/spawn/spawn.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index 166a7f49ebd0..f8fce232591d 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -606,8 +606,7 @@ describe("spawn unref and kill should not hang", () => { stderr: "ignore", stdin: "ignore", }); - // TODO: on Windows - if (!isWindows) proc.unref(); + proc.unref(); await proc.exited; } @@ -623,7 +622,7 @@ describe("spawn unref and kill should not hang", () => { }); proc.kill(); - if (!isWindows) proc.unref(); + proc.unref(); await proc.exited; console.count("Finished"); @@ -639,8 +638,7 @@ describe("spawn unref and kill should not hang", () => { stderr: "ignore", stdin: "ignore", }); - // TODO: on Windows - if (!isWindows) proc.unref(); + proc.unref(); proc.kill(); await proc.exited; } @@ -648,7 +646,6 @@ describe("spawn unref and kill should not hang", () => { expect().pass(); }); - // process.unref() on Windows does not work ye :( it("should not hang after unref", async () => { const proc = spawn({ cmd: [bunExe(), path.join(import.meta.dir, "does-not-hang.js")], @@ -777,10 +774,12 @@ it("await exited resolves after unref() when nothing else is ref'd (Windows)", a timeout: 20_000, }); const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); - expect(stderr).toBe(""); - expect(stdout).toBe("resolved\n"); - expect(child.signalCode).toBeNull(); - expect(exitCode).toBe(0); + expect({ stdout, stderr, exitCode, signalCode: child.signalCode }).toEqual({ + stdout: "resolved\n", + stderr: "", + exitCode: 0, + signalCode: null, + }); }); it("#3480", async () => { From a38bcfb07053e96efdac1bc5af85f1c3942a93f3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:54:46 +0000 Subject: [PATCH 03/10] Rework as a subprocess-targeted fix: re-ref the uv_process_t when .exited is observed The us_loop_pump bump regressed test-http-server-unconsume-consume.js on Windows: forcing a uv_run iteration on an idle loop also processes unref'd socket polls, and a half-closed socket in that test re-arms on every tick. Scope the fix to the subprocess path instead. Accessing .exited while the child is still running now keeps the uv_process_t ref'd (and records a flag so a subsequent unref() does not undo it). The uv_process_t stays in loop->active_handles, uv_run() polls IOCP, the wait-thread's exit packet is dequeued, and on_exit_uv fires. A script that spawns and unrefs without touching .exited still exits immediately (does-not-hang.js), so the unref semantic is preserved when the caller is not waiting. --- packages/bun-usockets/src/eventing/libuv.c | 9 ---- src/runtime/api/bun/subprocess.rs | 28 ++++++++++- src/spawn/process.rs | 4 +- test/js/bun/spawn/spawn.test.ts | 56 ++++++++++++---------- 4 files changed, 61 insertions(+), 36 deletions(-) diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index 606a6122287f..01c3a1932372 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -256,16 +256,7 @@ void us_internal_poll_set_type(struct us_poll_t *p, int poll_type) { LIBUS_SOCKET_DESCRIPTOR us_poll_fd(struct us_poll_t *p) { return p->fd; } void us_loop_pump(struct us_loop_t *loop) { - /* uv_run() skips its body entirely when uv__loop_alive() is 0, which means - * an unref'd handle's IOCP completion (e.g. a subprocess exit packet posted - * by the wait-thread) is never dequeued and its callback never fires. Bun - * calls this from outer loops (wait_for_promise, bun:test) that have their - * own "keep going" condition, so force exactly one non-blocking iteration: - * uv__poll(0) dequeues ready completions and uv__run_timers runs due - * timers. UV_RUN_NOWAIT keeps timeout at 0, so this never blocks. */ - loop->uv_loop->active_handles++; uv_run(loop->uv_loop, UV_RUN_NOWAIT); - loop->uv_loop->active_handles--; } struct us_loop_t *us_create_loop(void *hint, diff --git a/src/runtime/api/bun/subprocess.rs b/src/runtime/api/bun/subprocess.rs index a645363ff0e3..a4fb50643e45 100644 --- a/src/runtime/api/bun/subprocess.rs +++ b/src/runtime/api/bun/subprocess.rs @@ -302,6 +302,10 @@ bitflags::bitflags! { /// by the caller). Owned terminals are closed when the subprocess exits /// so the exit callback fires; borrowed terminals are left open for reuse. const OWNS_TERMINAL = 1 << 6; + /// `.exited` was observed while the child was still running. On Windows + /// this keeps the uv_process_t ref'd so its IOCP exit packet is dequeued + /// (see `get_exited` / `js_unref`). + const EXITED_PROMISE_PENDING = 1 << 7; } } @@ -543,7 +547,16 @@ impl Subprocess<'_> { /// This disables the keeping process alive flag on the poll and also in the stdin, stdout, and stderr pub fn js_unref(&self) { - self.process_mut().disable_keeping_event_loop_alive(); + // See `get_exited`: on Windows the uv_process_t must stay ref'd while a + // pending `.exited` promise exists, otherwise uv_run() never dequeues + // the wait-thread's IOCP exit packet and the promise never resolves. + #[cfg(windows)] + let skip_poller = self.flags.get().contains(Flags::EXITED_PROMISE_PENDING); + #[cfg(not(windows))] + let skip_poller = false; + if !skip_poller { + self.process_mut().disable_keeping_event_loop_alive(); + } if !self.has_called_getter(ObservableGetter::Stdin) { self.stdin.with_mut(|s| s.unref()); @@ -1362,6 +1375,19 @@ impl Subprocess<'_> { ) } _ => { + // On Windows, an unref'd uv_process_t drops out of + // loop->active_handles; with nothing else ref'd, uv_run() skips + // its body and never dequeues the wait-thread's IOCP exit + // packet, so on_exit_uv never fires and this promise never + // resolves. Accessing .exited while the child is still running + // is explicit intent to wait, so keep the handle ref'd; js_unref + // honours the flag so a later unref() cannot re-introduce the + // hang. + #[cfg(windows)] + { + self.update_flags(|f| f.insert(Flags::EXITED_PROMISE_PENDING)); + self.process_mut().enable_keeping_event_loop_alive(); + } let promise = JSPromise::create(global_this).to_js(); js::exited_promise_set_cached(this_value, global_this, promise); promise diff --git a/src/spawn/process.rs b/src/spawn/process.rs index d5a20c2468dd..d956ab969976 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -949,8 +949,8 @@ impl PollerWindows { // uv_unref() drops this handle from loop->active_handles. With nothing // else ref'd, uv__loop_alive() is 0 and uv_run() skips its body, so the // wait-thread's IOCP exit packet is never dequeued and on_exit_uv never - // fires. us_loop_pump() compensates by forcing one non-blocking - // iteration so the exit callback is still delivered. + // fires. Subprocess::get_exited re-refs the handle when .exited is + // observed while still Running, so awaiting it still resolves. match self { PollerWindows::Uv(p) => { p.unref(); diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index f8fce232591d..a3b8f9ec51a8 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -753,33 +753,41 @@ describe("should not hang", () => { } }); -it("await exited resolves after unref() when nothing else is ref'd (Windows)", async () => { +describe("await exited resolves after unref() when nothing else is ref'd (Windows)", () => { // On Windows, uv_unref() on the process handle drops it from // loop->active_handles. With nothing else ref'd, uv_run() skips its body // and never calls uv__poll, so the wait-thread's IOCP exit packet is never - // dequeued and on_exit_uv never fires. Before the fix, the child below - // would busy-spin forever with `exited` never resolving. - await using child = Bun.spawn({ - cmd: [ - bunExe(), - "-e", - `const p = Bun.spawn({ cmd: [${JSON.stringify(bunExe())}, "-e", ""], stdio: ["ignore", "ignore", "ignore"] }); - p.unref(); - await p.exited; - console.log("resolved");`, - ], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - timeout: 20_000, - }); - const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); - expect({ stdout, stderr, exitCode, signalCode: child.signalCode }).toEqual({ - stdout: "resolved\n", - stderr: "", - exitCode: 0, - signalCode: null, - }); + // dequeued and on_exit_uv never fires. Before the fix, the children below + // would busy-spin forever with `exited` never resolving. Accessing .exited + // while the child is still running now re-refs the handle so the exit + // callback is delivered, regardless of unref() ordering. + for (const [name, body] of [ + ["unref() then .exited", `p.unref(); await p.exited;`], + [".exited then unref()", `const done = p.exited; p.unref(); await done;`], + ] as const) { + it(name, async () => { + await using child = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const p = Bun.spawn({ cmd: [${JSON.stringify(bunExe())}, "-e", ""], stdio: ["ignore", "ignore", "ignore"] }); + ${body} + console.log("resolved");`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 20_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); + expect({ stdout, stderr, exitCode, signalCode: child.signalCode }).toEqual({ + stdout: "resolved\n", + stderr: "", + exitCode: 0, + signalCode: null, + }); + }); + } }); it("#3480", async () => { From a03de2f9b3beb3c1da958616f06baac79c939e1e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 17 Jul 2026 12:08:09 +0000 Subject: [PATCH 04/10] Trim comments to repo convention; drop redundant Bun.spawn timeout --- src/runtime/api/bun/subprocess.rs | 16 +++++----------- src/spawn/process.rs | 8 +++----- test/js/bun/spawn/spawn.test.ts | 11 +++-------- 3 files changed, 11 insertions(+), 24 deletions(-) diff --git a/src/runtime/api/bun/subprocess.rs b/src/runtime/api/bun/subprocess.rs index a4fb50643e45..dd15ee70b614 100644 --- a/src/runtime/api/bun/subprocess.rs +++ b/src/runtime/api/bun/subprocess.rs @@ -547,9 +547,8 @@ impl Subprocess<'_> { /// This disables the keeping process alive flag on the poll and also in the stdin, stdout, and stderr pub fn js_unref(&self) { - // See `get_exited`: on Windows the uv_process_t must stay ref'd while a - // pending `.exited` promise exists, otherwise uv_run() never dequeues - // the wait-thread's IOCP exit packet and the promise never resolves. + // See `get_exited`: on Windows the uv_process_t stays ref'd while a + // pending `.exited` promise exists so uv_run() dequeues its exit packet. #[cfg(windows)] let skip_poller = self.flags.get().contains(Flags::EXITED_PROMISE_PENDING); #[cfg(not(windows))] @@ -1375,14 +1374,9 @@ impl Subprocess<'_> { ) } _ => { - // On Windows, an unref'd uv_process_t drops out of - // loop->active_handles; with nothing else ref'd, uv_run() skips - // its body and never dequeues the wait-thread's IOCP exit - // packet, so on_exit_uv never fires and this promise never - // resolves. Accessing .exited while the child is still running - // is explicit intent to wait, so keep the handle ref'd; js_unref - // honours the flag so a later unref() cannot re-introduce the - // hang. + // Windows: an unref'd uv_process_t drops out of active_handles, + // uv_run() skips its body, and the IOCP exit packet is never + // dequeued. Re-ref so awaiting this promise actually resolves. #[cfg(windows)] { self.update_flags(|f| f.insert(Flags::EXITED_PROMISE_PENDING)); diff --git a/src/spawn/process.rs b/src/spawn/process.rs index d956ab969976..d866922e32c8 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -946,11 +946,9 @@ impl PollerWindows { } pub fn disable_keeping_event_loop_alive(&mut self, _event_loop: bun_io::EventLoopCtx) { - // uv_unref() drops this handle from loop->active_handles. With nothing - // else ref'd, uv__loop_alive() is 0 and uv_run() skips its body, so the - // wait-thread's IOCP exit packet is never dequeued and on_exit_uv never - // fires. Subprocess::get_exited re-refs the handle when .exited is - // observed while still Running, so awaiting it still resolves. + // uv_unref() drops this handle from active_handles; with nothing else + // ref'd, uv_run() skips its body and never dequeues the IOCP exit + // packet. Subprocess::get_exited re-refs once .exited is observed. match self { PollerWindows::Uv(p) => { p.unref(); diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index a3b8f9ec51a8..d56ad7d959d4 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -754,13 +754,9 @@ describe("should not hang", () => { }); describe("await exited resolves after unref() when nothing else is ref'd (Windows)", () => { - // On Windows, uv_unref() on the process handle drops it from - // loop->active_handles. With nothing else ref'd, uv_run() skips its body - // and never calls uv__poll, so the wait-thread's IOCP exit packet is never - // dequeued and on_exit_uv never fires. Before the fix, the children below - // would busy-spin forever with `exited` never resolving. Accessing .exited - // while the child is still running now re-refs the handle so the exit - // callback is delivered, regardless of unref() ordering. + // Windows: uv_unref() drops the uv_process_t from active_handles; with + // nothing else ref'd, uv_run() skips its body and never dequeues the IOCP + // exit packet, so the children below used to busy-spin forever. for (const [name, body] of [ ["unref() then .exited", `p.unref(); await p.exited;`], [".exited then unref()", `const done = p.exited; p.unref(); await done;`], @@ -777,7 +773,6 @@ describe("await exited resolves after unref() when nothing else is ref'd (Window env: bunEnv, stdout: "pipe", stderr: "pipe", - timeout: 20_000, }); const [stdout, stderr, exitCode] = await Promise.all([child.stdout.text(), child.stderr.text(), child.exited]); expect({ stdout, stderr, exitCode, signalCode: child.signalCode }).toEqual({ From 26de6ee1dd52e268f00f1f0f59000cbe2727b12e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 01:16:39 +0000 Subject: [PATCH 05/10] Redo as the structural fix: us_loop_pump parity + stop re-dispatching EOF on allow_half_open sockets tick_without_idle on POSIX (us_loop_run_bun_tick) polls epoll/kqueue and dispatches regardless of ref state (only early-outs on num_polls==0). On Windows (us_loop_pump -> uv_run(UV_RUN_NOWAIT)), libuv's uv__loop_alive() guard skips the body when only unref'd handles remain, so IOCP completions for unref'd handles and due timers are never processed. That asymmetry is the underlying bug; spawn.test.ts is one symptom. Bracket uv_run(UV_RUN_NOWAIT) with an active_handles bump so it always executes one non-blocking iteration (POSIX parity). This surfaced a pre-existing allow_half_open socket loop on Windows: after the first EOF, loop.c leaves the poll writable-only, but us_poll_change always re-adds UV_DISCONNECT. AFD keeps signalling DISCONNECT level- triggered, poll_cb maps it to READABLE, recv(0) re-dispatches EOF, and the allow_half_open branch re-arms WRITABLE, so the poll bounces forever and onDrain posts a task every tick (the test-http-server-unconsume-consume.js CONNECT-tunnel socket hits this). Drop repeated DISCONNECT once readable polling has already been stopped. Drops the earlier subprocess-specific get_exited re-ref workaround. --- packages/bun-usockets/src/eventing/libuv.c | 20 ++++++++++++++++++++ src/runtime/api/bun/subprocess.rs | 22 +--------------------- src/spawn/process.rs | 6 +++--- test/js/bun/spawn/spawn.test.ts | 19 +++++++++++++------ 4 files changed, 37 insertions(+), 30 deletions(-) diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index 01c3a1932372..abee879b64b6 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -126,10 +126,21 @@ static void poll_cb(uv_poll_t *p, int status, int events) { sock->group->loop->data.fin_deferred_count++; } } + } else if (kind == POLL_TYPE_SOCKET && + us_internal_poll_cb_adopted_socket(wp)->flags.allow_half_open && + !(wp->poll_type & POLL_TYPE_POLLING_IN)) { + /* allow_half_open already delivered EOF (readable polling was stopped + * and the poll left writable-only in loop.c). AFD keeps signalling + * DISCONNECT level-triggered, so drop it instead of re-dispatching EOF + * on every tick; otherwise the writable/EOF handlers bounce the poll + * between 0 and WRITABLE and us_loop_pump never goes idle. */ } else { events |= UV_READABLE; } } + if (!error && !eof && !(events & (UV_READABLE | UV_WRITABLE))) { + return; + } us_internal_dispatch_ready_poll((struct us_poll_t *)p->data, error, eof, events); } @@ -256,7 +267,16 @@ void us_internal_poll_set_type(struct us_poll_t *p, int poll_type) { LIBUS_SOCKET_DESCRIPTOR us_poll_fd(struct us_poll_t *p) { return p->fd; } void us_loop_pump(struct us_loop_t *loop) { + /* POSIX parity: us_loop_run_bun_tick polls epoll/kqueue and dispatches + * regardless of ref state (it only early-outs on num_polls == 0). libuv's + * uv_run() skips its body when uv__loop_alive() is 0, so IOCP completions + * for unref'd handles (subprocess exit packets, socket events) and due + * timers are never processed. Bun's outer drive loops (wait_for_promise, + * bun:test) supply their own keep-going predicate, so force exactly one + * non-blocking iteration; UV_RUN_NOWAIT keeps the poll timeout at 0. */ + loop->uv_loop->active_handles++; uv_run(loop->uv_loop, UV_RUN_NOWAIT); + loop->uv_loop->active_handles--; } struct us_loop_t *us_create_loop(void *hint, diff --git a/src/runtime/api/bun/subprocess.rs b/src/runtime/api/bun/subprocess.rs index dd15ee70b614..a645363ff0e3 100644 --- a/src/runtime/api/bun/subprocess.rs +++ b/src/runtime/api/bun/subprocess.rs @@ -302,10 +302,6 @@ bitflags::bitflags! { /// by the caller). Owned terminals are closed when the subprocess exits /// so the exit callback fires; borrowed terminals are left open for reuse. const OWNS_TERMINAL = 1 << 6; - /// `.exited` was observed while the child was still running. On Windows - /// this keeps the uv_process_t ref'd so its IOCP exit packet is dequeued - /// (see `get_exited` / `js_unref`). - const EXITED_PROMISE_PENDING = 1 << 7; } } @@ -547,15 +543,7 @@ impl Subprocess<'_> { /// This disables the keeping process alive flag on the poll and also in the stdin, stdout, and stderr pub fn js_unref(&self) { - // See `get_exited`: on Windows the uv_process_t stays ref'd while a - // pending `.exited` promise exists so uv_run() dequeues its exit packet. - #[cfg(windows)] - let skip_poller = self.flags.get().contains(Flags::EXITED_PROMISE_PENDING); - #[cfg(not(windows))] - let skip_poller = false; - if !skip_poller { - self.process_mut().disable_keeping_event_loop_alive(); - } + self.process_mut().disable_keeping_event_loop_alive(); if !self.has_called_getter(ObservableGetter::Stdin) { self.stdin.with_mut(|s| s.unref()); @@ -1374,14 +1362,6 @@ impl Subprocess<'_> { ) } _ => { - // Windows: an unref'd uv_process_t drops out of active_handles, - // uv_run() skips its body, and the IOCP exit packet is never - // dequeued. Re-ref so awaiting this promise actually resolves. - #[cfg(windows)] - { - self.update_flags(|f| f.insert(Flags::EXITED_PROMISE_PENDING)); - self.process_mut().enable_keeping_event_loop_alive(); - } let promise = JSPromise::create(global_this).to_js(); js::exited_promise_set_cached(this_value, global_this, promise); promise diff --git a/src/spawn/process.rs b/src/spawn/process.rs index d866922e32c8..1727c8c7a9a9 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -946,9 +946,9 @@ impl PollerWindows { } pub fn disable_keeping_event_loop_alive(&mut self, _event_loop: bun_io::EventLoopCtx) { - // uv_unref() drops this handle from active_handles; with nothing else - // ref'd, uv_run() skips its body and never dequeues the IOCP exit - // packet. Subprocess::get_exited re-refs once .exited is observed. + // uv_unref() drops this handle from loop->active_handles. us_loop_pump + // forces a non-blocking uv_run iteration regardless, so the + // wait-thread's IOCP exit packet is still dequeued and on_exit_uv fires. match self { PollerWindows::Uv(p) => { p.unref(); diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index d56ad7d959d4..31fe187ea3b4 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -753,20 +753,27 @@ describe("should not hang", () => { } }); -describe("await exited resolves after unref() when nothing else is ref'd (Windows)", () => { +describe("unref() + .exited with nothing else ref'd (Windows)", () => { // Windows: uv_unref() drops the uv_process_t from active_handles; with - // nothing else ref'd, uv_run() skips its body and never dequeues the IOCP - // exit packet, so the children below used to busy-spin forever. + // nothing else ref'd, uv_run() used to skip its body and never dequeue the + // IOCP exit packet, so these children busy-spun forever with exited never + // resolving. us_loop_pump now forces one non-blocking iteration, matching + // POSIX's us_loop_run_bun_tick. for (const [name, body] of [ - ["unref() then .exited", `p.unref(); await p.exited;`], - [".exited then unref()", `const done = p.exited; p.unref(); await done;`], + ["unref() then await .exited", `const p = Bun.spawn(opts); p.unref(); await p.exited;`], + [".exited then unref() then await", `const p = Bun.spawn(opts); const done = p.exited; p.unref(); await done;`], + [ + "onExit then unref()", + `const { promise, resolve } = Promise.withResolvers(); + const p = Bun.spawn({ ...opts, onExit: resolve }); p.unref(); await promise;`, + ], ] as const) { it(name, async () => { await using child = Bun.spawn({ cmd: [ bunExe(), "-e", - `const p = Bun.spawn({ cmd: [${JSON.stringify(bunExe())}, "-e", ""], stdio: ["ignore", "ignore", "ignore"] }); + `const opts = { cmd: [${JSON.stringify(bunExe())}, "-e", ""], stdio: ["ignore", "ignore", "ignore"] }; ${body} console.log("resolved");`, ], From 4d5417ba2e641af902f15066b5b40a2d4888a812 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:25:22 +0000 Subject: [PATCH 06/10] poll_cb: discriminate RST from the repeated FIN in the allow_half_open post-EOF arm UV_DISCONNECT also carries AFD_POLL_ABORT (RST) via the win-poll-abort-with-disconnect patch. The empty arm dropped both, so an RST arriving after FIN on a writable-only allow_half_open socket was silently lost (diverging from epoll's unmaskable EPOLLERR). Mirror the sibling paused-socket arm: SO_ERROR + zero-byte-send probe surfaces the reset as an error dispatch, and fin_deferred lets the sweep timer escalate a later reset once DISCONNECT is disarmed. --- packages/bun-usockets/src/eventing/libuv.c | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index abee879b64b6..1c8651d5a742 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -131,9 +131,25 @@ static void poll_cb(uv_poll_t *p, int status, int events) { !(wp->poll_type & POLL_TYPE_POLLING_IN)) { /* allow_half_open already delivered EOF (readable polling was stopped * and the poll left writable-only in loop.c). AFD keeps signalling - * DISCONNECT level-triggered, so drop it instead of re-dispatching EOF - * on every tick; otherwise the writable/EOF handlers bounce the poll - * between 0 and WRITABLE and us_loop_pump never goes idle. */ + * DISCONNECT level-triggered, so drop the repeated FIN instead of + * re-dispatching EOF on every tick (the writable/EOF handlers would + * otherwise bounce the poll between 0 and WRITABLE forever). But + * UV_DISCONNECT also carries AFD_POLL_ABORT (RST) via the + * win-poll-abort-with-disconnect patch, which must surface like + * epoll's unmaskable EPOLLERR: discriminate with the same SO_ERROR + + * zero-byte-send probe as the paused-socket arm above, and mark + * fin_deferred so the sweep timer escalates a later reset now that + * DISCONNECT is disarmed. */ + struct us_socket_t *sock = us_internal_poll_cb_adopted_socket(wp); + if (!sock->flags.is_closed && + (us_socket_get_error(sock) != 0 || + us_internal_libuv_peer_reset_probe(us_poll_fd(wp)))) { + error = 1; + events |= UV_READABLE; + } else if (!sock->fin_deferred) { + sock->fin_deferred = 1; + sock->group->loop->data.fin_deferred_count++; + } } else { events |= UV_READABLE; } From 415d7dacfe823a3b8edcaf38e26c252511ef70ab Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:39:54 +0000 Subject: [PATCH 07/10] test: cover the unref'd-timer path and the CONNECT-tunnel allow_half_open bounce Un-skip abort.ts:113 (timeout works) and add the #33334 repro to abort.test.ts in a subprocess so a regression is an attributable failure. Add a bun:test that runs the test-http-server-unconsume-consume CONNECT pattern in a subprocess and asserts 'end' fires exactly once and the process exits, pinning the poll_cb allow_half_open branch. --- test/js/node/http/node-http-connect.test.ts | 41 +++++++++++++++++++++ test/js/web/abort/abort.test.ts | 32 +++++++++++++++- test/js/web/abort/abort.ts | 3 +- 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/test/js/node/http/node-http-connect.test.ts b/test/js/node/http/node-http-connect.test.ts index 92937f8c438b..077fb33db806 100644 --- a/test/js/node/http/node-http-connect.test.ts +++ b/test/js/node/http/node-http-connect.test.ts @@ -780,3 +780,44 @@ describe("Should be compatible with node.js", () => { expect(await process.exited).toBe(0); }); }); + +// Windows: after the CONNECT-tunnel socket receives FIN (allow_half_open leaves +// the poll writable-only), AFD's level-triggered UV_DISCONNECT used to bounce +// the poll between 0 and WRITABLE forever, posting an onDrain task every tick. +// That was masked while us_loop_pump skipped unref'd handles; once it polls +// them, the process never exits. test-http-server-unconsume-consume.js is the +// node/parallel version, but a 20s runner timeout is a poor signal. +test("CONNECT: process exits after the tunnel socket is re-emitted as a connection and the server closes", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const http = require("node:http"); + let endCount = 0; + const server = http.createServer(() => { throw new Error("request listener should not run"); }); + server.on("connect", (req, socket) => { + socket.on("end", () => endCount++); + socket.write("HTTP/1.1 200 Connection Established\\r\\n\\r\\n"); + server.emit("connection", socket); + server.close(); + }); + server.listen(0, () => { + http.request({ port: server.address().port, method: "CONNECT" }).end(); + }); + process.on("exit", () => { + if (endCount !== 1) throw new Error("end fired " + endCount + " times (expected 1)"); + console.log("ok"); + });`, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout, stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stdout: "ok\n", + stderr: "", + exitCode: 0, + signalCode: null, + }); +}); diff --git a/test/js/web/abort/abort.test.ts b/test/js/web/abort/abort.test.ts index 48c19fd1c347..be3f0b879c4f 100644 --- a/test/js/web/abort/abort.test.ts +++ b/test/js/web/abort/abort.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { writeFileSync } from "fs"; -import { bunEnv, bunExe, tmpdirSync } from "harness"; +import { bunEnv, bunExe, tempDir, tmpdirSync } from "harness"; import { tmpdir } from "os"; import { join } from "path"; @@ -87,4 +87,34 @@ describe("AbortSignal", () => { expect(fmt(ac.reason)).toEqual(fmt(new DOMException("The operation timed out.", "TimeoutError"))); expect(ac.reason.code).toBe(23); }); + + // #33334: awaiting the abort event with nothing else ref'd used to hang the + // whole test file on Windows (uv_run skipped its body with active_handles=0 + // so uv__run_timers never ran). Run in a subprocess so a regression is an + // attributable failure instead of a file-level timeout. + test("awaiting AbortSignal.timeout(n) abort event with nothing else ref'd does not hang (#33334)", async () => { + using dir = tempDir("abort-33334", { + "timeout.test.ts": `import { expect, test } from "bun:test"; + test("AbortSignal.timeout fires", async () => { + const signal = AbortSignal.timeout(1); + const { promise, resolve } = Promise.withResolvers(); + signal.addEventListener("abort", resolve, { once: true }); + await promise; + expect(signal.aborted).toBe(true); + });`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "timeout.test.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr: stderr.includes("1 pass") ? "1 pass" : stderr, exitCode, signalCode: proc.signalCode }).toEqual({ + stderr: "1 pass", + exitCode: 0, + signalCode: null, + }); + }); }); diff --git a/test/js/web/abort/abort.ts b/test/js/web/abort/abort.ts index c591ae5a2dd5..d99718037e38 100644 --- a/test/js/web/abort/abort.ts +++ b/test/js/web/abort/abort.ts @@ -110,8 +110,7 @@ describe("AbortSignal", () => { expect(() => AbortSignal.timeout(timeout)).toThrow(TypeError); }); } - // FIXME: test runner hangs when this is enabled - test.skip("timeout works", done => { + test("timeout works", done => { const abort = AbortSignal.timeout(1); abort.addEventListener("abort", event => { done(); From 28646450af568768ac590a86f9cc1ba9a92b46db Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 02:50:54 +0000 Subject: [PATCH 08/10] test: trim comments to 3 lines per repo convention --- test/js/bun/spawn/spawn.test.ts | 8 +++----- test/js/node/http/node-http-connect.test.ts | 9 +++------ test/js/web/abort/abort.test.ts | 7 +++---- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index 31fe187ea3b4..ce9dea8b2694 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -754,11 +754,9 @@ describe("should not hang", () => { }); describe("unref() + .exited with nothing else ref'd (Windows)", () => { - // Windows: uv_unref() drops the uv_process_t from active_handles; with - // nothing else ref'd, uv_run() used to skip its body and never dequeue the - // IOCP exit packet, so these children busy-spun forever with exited never - // resolving. us_loop_pump now forces one non-blocking iteration, matching - // POSIX's us_loop_run_bun_tick. + // Windows: with only an unref'd uv_process_t left, uv_run() used to skip its + // body and never dequeue the IOCP exit packet, so these children busy-spun + // forever. us_loop_pump now forces one non-blocking iteration (POSIX parity). for (const [name, body] of [ ["unref() then await .exited", `const p = Bun.spawn(opts); p.unref(); await p.exited;`], [".exited then unref() then await", `const p = Bun.spawn(opts); const done = p.exited; p.unref(); await done;`], diff --git a/test/js/node/http/node-http-connect.test.ts b/test/js/node/http/node-http-connect.test.ts index 077fb33db806..e776a36c079f 100644 --- a/test/js/node/http/node-http-connect.test.ts +++ b/test/js/node/http/node-http-connect.test.ts @@ -781,12 +781,9 @@ describe("Should be compatible with node.js", () => { }); }); -// Windows: after the CONNECT-tunnel socket receives FIN (allow_half_open leaves -// the poll writable-only), AFD's level-triggered UV_DISCONNECT used to bounce -// the poll between 0 and WRITABLE forever, posting an onDrain task every tick. -// That was masked while us_loop_pump skipped unref'd handles; once it polls -// them, the process never exits. test-http-server-unconsume-consume.js is the -// node/parallel version, but a 20s runner timeout is a poor signal. +// Windows: after FIN on a CONNECT-tunnel socket, AFD's level-triggered +// UV_DISCONNECT used to re-derive EOF and bounce the poll between 0 and +// WRITABLE forever (pins the poll_cb allow_half_open arm). test("CONNECT: process exits after the tunnel socket is re-emitted as a connection and the server closes", async () => { await using proc = Bun.spawn({ cmd: [ diff --git a/test/js/web/abort/abort.test.ts b/test/js/web/abort/abort.test.ts index be3f0b879c4f..912da50e3469 100644 --- a/test/js/web/abort/abort.test.ts +++ b/test/js/web/abort/abort.test.ts @@ -88,10 +88,9 @@ describe("AbortSignal", () => { expect(ac.reason.code).toBe(23); }); - // #33334: awaiting the abort event with nothing else ref'd used to hang the - // whole test file on Windows (uv_run skipped its body with active_handles=0 - // so uv__run_timers never ran). Run in a subprocess so a regression is an - // attributable failure instead of a file-level timeout. + // #33334: with nothing else ref'd, uv_run() skipped its body on Windows so + // uv__run_timers never ran and the whole file hung. Subprocess so a + // regression is an attributable failure, not a file-level timeout. test("awaiting AbortSignal.timeout(n) abort event with nothing else ref'd does not hang (#33334)", async () => { using dir = tempDir("abort-33334", { "timeout.test.ts": `import { expect, test } from "bun:test"; From 619b61f827622ec287ea4987e316c9af290d3902 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 20 Jul 2026 03:12:36 +0000 Subject: [PATCH 09/10] peer_reset_probe: treat WSAESHUTDOWN as non-fatal; gate the allow_half_open arm on !is_closed WSAESHUTDOWN from send() after a local shutdown(SD_SEND) is not a peer reset, but the fin_deferred sweep probes sockets after local shutdown and would have reported it as CONNECTION_RESET instead of lingering. Also gate the whole allow_half_open arm body on !is_closed (matching the paused-socket sibling) so a completion racing close cannot re-set fin_deferred on an already-unlinked socket and leak fin_deferred_count. --- packages/bun-usockets/src/eventing/libuv.c | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index 1c8651d5a742..df6d279c2941 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -36,7 +36,10 @@ int us_internal_libuv_peer_reset_probe(LIBUS_SOCKET_DESCRIPTOR fd) { if (send(fd, "", 0, 0) != SOCKET_ERROR) { return 0; } - return WSAGetLastError() != WSAEWOULDBLOCK; + int err = WSAGetLastError(); + /* WSAESHUTDOWN means our own shutdown(SD_SEND) ran; that is not a peer + * reset. The fin_deferred sweep probes sockets after local shutdown. */ + return err != WSAEWOULDBLOCK && err != WSAESHUTDOWN; } static struct us_socket_t *us_internal_poll_cb_adopted_socket(struct us_poll_t *wp) { @@ -141,9 +144,11 @@ static void poll_cb(uv_poll_t *p, int status, int events) { * fin_deferred so the sweep timer escalates a later reset now that * DISCONNECT is disarmed. */ struct us_socket_t *sock = us_internal_poll_cb_adopted_socket(wp); - if (!sock->flags.is_closed && - (us_socket_get_error(sock) != 0 || - us_internal_libuv_peer_reset_probe(us_poll_fd(wp)))) { + if (sock->flags.is_closed) { + /* close_raw already cleared fin_deferred and unlinked from the group; + * setting it here would leak fin_deferred_count. */ + } else if (us_socket_get_error(sock) != 0 || + us_internal_libuv_peer_reset_probe(us_poll_fd(wp))) { error = 1; events |= UV_READABLE; } else if (!sock->fin_deferred) { From f426d9a59b4b2019f748d77b68dd8777826a61ff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:41:40 +0000 Subject: [PATCH 10/10] test(appcontainer): rebuild the PROC_THREAD_ATTRIBUTE_LIST inside launchInContainer Reusing the module-load-time attribute list across later event-loop turns proved fragile once us_loop_pump runs a uv_run iteration between module load and the test body: CreateProcessW started returning ERROR_INVALID_PARAMETER for the second call even though the backing buffers and SID bytes were byte-identical when inspected (inspection itself perturbed the failure away). Re-initializing the list into the same backing buffers on each launch is cheap and avoids whatever interaction that is. --- test/js/bun/windows/appcontainer.test.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/test/js/bun/windows/appcontainer.test.ts b/test/js/bun/windows/appcontainer.test.ts index a2ef1e66f8a1..c98daaf770cc 100644 --- a/test/js/bun/windows/appcontainer.test.ts +++ b/test/js/bun/windows/appcontainer.test.ts @@ -128,16 +128,20 @@ if (isWindows) { const sizeOut = Buffer.alloc(8); kernel32.InitializeProcThreadAttributeList(null, 1, 0, ptr(sizeOut)); const attrList = Buffer.alloc(Number(sizeOut.readBigUInt64LE(0))); - if (kernel32.InitializeProcThreadAttributeList(ptr(attrList), 1, 0, ptr(sizeOut)) === 0) - throw new Error("InitializeProcThreadAttributeList"); - // PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x20009 - if ( - kernel32.UpdateProcThreadAttribute(ptr(attrList), 0, 0x20009n as any, ptr(secCaps), 24n as any, null, null) === 0 - ) - throw new Error("UpdateProcThreadAttribute"); - keepAlive = [attrList, secCaps]; + keepAlive = [attrList, secCaps, sizeOut]; launchInContainer = (cmdline: string, cwd: string, timeoutMs: number): number => { + // The attribute list is rebuilt on each call (same backing buffers): + // reusing a module-load-time list across later event-loop turns proved + // fragile (CreateProcessW started returning ERROR_INVALID_PARAMETER). + if (kernel32.InitializeProcThreadAttributeList(ptr(attrList), 1, 0, ptr(sizeOut)) === 0) + throw new Error("InitializeProcThreadAttributeList"); + // PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x20009 + if ( + kernel32.UpdateProcThreadAttribute(ptr(attrList), 0, 0x20009n as any, ptr(secCaps), 24n as any, null, null) === + 0 + ) + throw new Error("UpdateProcThreadAttribute"); // STARTUPINFOEXW: 104-byte STARTUPINFOW (cb=112) + lpAttributeList. const siex = Buffer.alloc(112); siex.writeUInt32LE(112, 0);