Skip to content

usockets(windows): keep a closed poll alive until the outer tick and libuv are done with it - #39643

Open
robobun wants to merge 1 commit into
mainfrom
farm/47550167/win-poll-free-protocol
Open

usockets(windows): keep a closed poll alive until the outer tick and libuv are done with it#39643
robobun wants to merge 1 commit into
mainfrom
farm/47550167/win-poll-free-protocol

Conversation

@robobun

@robobun robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Windows crashes after a handler returns: us_socket_is_closed or us_internal_socket_follow_adopted at 0xFFFFFFFFFFFFFFFF (BUN-43AH, BUN-4NC8), us_internal_socket_close_raw on a freed socket (BUN-442H, BUN-442Y, BUN-4NP3), a garbage poll_cb called from uv__fast_poll_process_poll_req (BUN-442Z), mimalloc free-list crashes, and the test/bake/deinitialization.test.ts failures on the Windows lanes. The 1.4.0 release (34cbb9a40) reports this family from bun test on Windows (as of 2026-08-21: BUN-442H 12 events, BUN-4NC8 6, BUN-442Y 5, BUN-4NP3 2, BUN-442Z 1) and the TLS forms BUN-4MNY, BUN-4NFZ, BUN-4NJG and BUN-4NKV, one each.
  • loop.c:450 frees closed sockets only at tick_depth <= 1. The libuv backend never counts it, so a handler that waits for a promise (a nested uv_run) frees the socket the outer dispatch still reads (loop.c:746).
  • The nested run also finishes closing that socket's uv_poll_t under libuv's outer uv__fast_poll_process_poll_req frame. That frame then queues the endgame again, and close_cb_free_poll runs twice. uv_run is documented as not reentrant, so the misuse is ours.

Fix

  • us_loop_run and us_loop_pump count tick_depth, as the POSIX backend does.
  • While a poll_cb frame is on the stack (poll_cb_depth), us_poll_stop only disarms the handle. The outermost frame calls uv_close on its way back into libuv, which libuv supports, and then closes the socket (us_internal_poll_close_fd), so libuv sees the same order as before. libuv is unchanged.
  • us_poll_free and close_cb_free_poll record who ran first (released, uv_closed). The second one frees both blocks. The old data = NULL handshake leaked when the callback ran first (usockets(win): free the poll when us_poll_free runs after the uv close callback #37105). A stopped poll is never re-armed or re-initialized.
  • Verified: the new case in test/js/bun/net/socket.test.ts. On a Windows debug build of main its child segfaults at 0xFFFFFFFFFFFFFFFF (the BUN-43AH signature), and the release canary crashes too. It passes with the fix (Windows x64 and arm64 debug, Linux ASAN). appcontainer.test.ts gets a step for the socket order. Notes have the rest.

Background

  • On Windows a socket is two blocks: the us_socket_t (it starts with the us_poll_t) and a libuv uv_poll_t. libuv's in-flight AFD requests live inside the uv_poll_t, so it has to live until the close callback. A closed socket waits on loop->data.closed_head until us_internal_loop_post frees it, and loop.c reads it after its handlers return.
  • Endgame: uv_close cancels the requests. When the last one completes, libuv queues the endgame, which unlinks the handle and runs the close callback. uv__fast_poll_process_poll_req checks for it right after poll_cb returns.
  • Nested tick: wait_for_promise (expect().resolves, auto-install) runs uv_run inside the handler.
Notes

Symbolized fail-before (Windows x64 debug build of main a35696478d, a standalone copy of the new test's fixture. The test itself fails on that build with the same fault address):

panic(main thread): Segmentation fault at address 0xFFFFFFFFFFFFFFFF
us_internal_socket_follow_adopted   packages/bun-usockets/src/internal/internal.h:357
us_internal_dispatch_ready_poll     packages/bun-usockets/src/loop.c:746   (after us_dispatch_data returned)
poll_cb                             packages/bun-usockets/src/eventing/libuv.c:165
uv__fast_poll_process_poll_req      vendor/libuv/src/win/poll.c:233
uv__process_reqs / uv_run           vendor/libuv/src/win/core.c
us_loop_run                         packages/bun-usockets/src/eventing/libuv.c:417

The freed socket is filled with mimalloc's debug poison, so flags.adopted reads as set and prev is followed. The open() variant crashes the same way at loop.c:556. The same fixture without the nested wait passes on that build. The release canary (1.4.0-canary.1+32e87032b) passes the first case and crashes in the second, at 0xFFFFFFFFFFFFFFFF in one run and 0x1AF0000002A in another, which is the heap corruption from the double free.

How the three Sentry shapes follow. In release, mi_free overwrites the first word of the freed uv_poll_t, which is data. The rest stays intact, so the outer frame sees events == 0, CLOSING, and no requests in flight, and queues the endgame again. The second close_cb_free_poll frees h->data, now the free-list link to another freed block, and h itself again. Later allocations alias (a garbage poll_cb, BUN-442Z, or us_poll_start_rc faulting at 0 in deinitialization.test.ts), or the freed socket is reused and the outer dispatch runs the error close on it (BUN-442Y), or follow_adopted reads a reused block (BUN-43AH).

Socket order, found by CI. The first push closed the socket in close_raw before the deferred uv_close. appcontainer.test.ts failed on the Windows 11 arm64 lane with exit 0xC0000008 (STATUS_INVALID_HANDLE), and failed 10 of 10 runs on an arm64 machine with that build against 5 of 5 passes with main's libuv.c on the same machine. GetProcessMitigationPolicy(ProcessStrictHandleCheckPolicy) inside the container reports 0x3, so a call on a closed handle raises instead of failing, and uv__poll_close cancels the in-flight request with an ioctl on the socket. A probe with four steps (listener only, serve + fetch, terminate from data(), terminate from a timer with the peer closed by its own dispatch) died in every step that closes a socket from a dispatch. With us_internal_poll_close_fd the four steps and the test pass (10 of 10 runs on arm64, and on x64). The test now also closes a socket from its own data() handler, which is the shortest path to this order. Outside a nested tick, the later socket close is not observable from JS: the loop does not run again before the handler's dispatch ends. Inside one, a handler that closes its socket and then waits for the peer to notice now waits until the handler returns. That wait was already unreliable on Windows (the peer's completion is often in the outer run's batch) and then corrupted the heap.

Why the hand-off is needed together with tick_depth. With tick_depth alone, every socket closed during a nested tick with no live frame finishes closing in the inner run, and the deferred us_poll_free then met a handle whose close callback had already run: the old code handed it data and leaked both blocks. #37105 fixes that case on its own with a marker in data. This PR covers it with the two bits.

Defensive branches with no current caller: us_poll_free on a poll that was never stopped, us_poll_start_rc on a registered poll (mask change, or UV_EBADF once stopped), us_poll_change after stop. Every current caller creates, starts, and later stops a poll exactly once. The uv_poll_init_socket failure path now goes through us_poll_stop. uv_poll_stop on a half-initialized handle only clears events (checked against uv__poll_set). That path was reasoned about, not run: it needs a --socketFaultInjection=on build, which rebuilds every Rust crate.

Suites run on the Windows x64 debug build with the fix, all green: test/js/bun/net/socket.test.ts (86 pass), tcp-server, socket-retention, socket-syscall-fault, test/js/bun/udp/udp_socket.test.ts, test/js/bun/websocket/websocket-server.test.ts, test/js/node/net/node-net-server.test.ts, test/js/node/tls/node-tls-server.test.ts, test/js/node/http/node-http.test.ts, test/js/bun/http/serve.test.ts (294 pass), test/bake/deinitialization.test.ts 3 of 3 (the unfixed release canary hangs for 60 s and is killed on the same machine). test/js/web/fetch/fetch.test.ts had 14 timeouts of (with gc) body tests on this slow debug box. The two socket tests among them pass in isolation. Per the analysis in #33018, deinitialization.test.ts closes the socket from a nested poll_cb on the same handle, so it also covers poll_cb_depth > 1.

Sentry groups on the 1.4.0 release (34cbb9a40, all Windows): BUN-4NC8 is follow_adopted (internal.h:357) under loop.c:746, the frames of the fail-before above. BUN-442H is close_raw at socket.c:292, the low-prio unlink writing through s->prev of a reused block, reached from the eof or error close at the end of the dispatch (BUN-442Y and BUN-4NP3 are the same call: the unlink at context.c:235 and :240 writing through the reused block's prev and next, and us_internal_disable_sweep_timer through its group). BUN-4MNY is ssl_retry_parked_write (openssl.c:2103, s->group read as NULL) in the tail of us_internal_ssl_on_data after us_dispatch_data returned: the TLS form of the same read of a freed socket. BUN-4NFZ is the other exit of that tail: us_internal_ssl_close (openssl.c:1927, again s->group NULL) from the ssl_close call at openssl.c:2324 after the data callback. BUN-4NJG and BUN-4NKV are the keylog and session flushes in the same tail (openssl.c:2430 to :351, and :2429 to :426): s->ssl read from the reused block, so SSL_get_ex_data returns garbage. Both flushes are guarded by !s->ssl || is_closed, which only a reused block gets past. The four TLS groups are the four helpers of that tail, in whichever order a given reused block fails. With the closed list left alone until the outermost tick, ssl_gone, is_closed and s->ssl read the real state there and those tails return.

Limit. tick_depth counts the ticks Bun itself runs (us_loop_run, us_loop_pump). A native addon that calls uv_run() on this loop from inside a handler is not counted. The socket whose own callback is on the stack is still safe there, because its uv_close and frees are tied to poll_cb_depth, but a socket that another frame holds (an accepted socket closed from its own open() while the accept loop still reads it, or a block retired by adoption) is not. Counting the frames of the callbacks libuv runs for us (poll_cb, prepare_cb, timer_cb, async_cb) would cover that entry as well. None of the crashes above needed it, so it is left for a follow-up rather than re-verifying this PR for it.

Release-build numbers for test/bake/deinitialization.test.ts are in the comments below: on one Windows Server 2019 machine the fixture fails 8 of 8 runs on main (6 hangs, 2 segfaults) and passes 20 of 20 with this diff.

Related. #33018 was an earlier attempt at this bug. It patched libuv's post-callback check instead of moving the uv_close. #38024 has the same bug class in the c-ares poll and still carries that patch. Deferring its uv_close past the outermost callback frame would remove the need for it there too. The test fixture waits on a timer on purpose: a nested run cannot wait for an event of the peer socket, because that completion may sit in the outer run's IOCP batch.


no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/windows/appcontainer.test.ts, test/js/bun/net/socket.test.ts

@coderabbitai

coderabbitai Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f96179cb-a3c2-4c4a-b1e1-bcd4a7e352a6

📥 Commits

Reviewing files that changed from the base of the PR and between 4448a2e and ca25925.

📒 Files selected for processing (10)
  • packages/bun-usockets/src/context.c
  • packages/bun-usockets/src/eventing/epoll_kqueue.c
  • packages/bun-usockets/src/eventing/libuv.c
  • packages/bun-usockets/src/internal/eventing/libuv.h
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/internal/loop_data.h
  • packages/bun-usockets/src/socket.c
  • packages/bun-usockets/src/udp.c
  • test/js/bun/net/socket.test.ts
  • test/js/bun/windows/appcontainer.test.ts

Included review availability: 4 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour.


Walkthrough

Changes

Socket poll lifecycle

Layer / File(s) Summary
Poll lifecycle and backend closure
packages/bun-usockets/src/internal/eventing/libuv.h, packages/bun-usockets/src/internal/internal.h, packages/bun-usockets/src/internal/loop_data.h, packages/bun-usockets/src/eventing/libuv.c, packages/bun-usockets/src/eventing/epoll_kqueue.c
Poll state now tracks stopping, release, closure, deferred descriptor handling, and nested callbacks. Libuv poll operations coordinate handle cleanup and always subscribe to disconnect events.
Socket close-path wiring
packages/bun-usockets/src/socket.c, packages/bun-usockets/src/udp.c, packages/bun-usockets/src/context.c
Socket, UDP, and listening descriptor closure paths now use us_internal_poll_close_fd.
Socket closure regression coverage
test/js/bun/net/socket.test.ts, test/js/bun/windows/appcontainer.test.ts
Tests cover socket closure during data() and open() callbacks, including repeated subprocess execution and Windows AppContainer behavior.

Suggested reviewers: cirospaciari, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the Windows libuv poll-lifetime fix during nested event-loop ticks.
Description check ✅ Passed The description explains the problem, implementation, background, and verification results in substantial detail.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready to merge. Head 174085f6ee is the diff of f91d44edb8 rebased onto main as one commit (content identical, verified by diffing the two patches). Build 103029 on it has one red test, test/cli/install/bun-audit.test.ts, which is broken on main by the 1.4.1 version bump and reported there. The rest passed or passed on retry. The only rebase conflict was in libuv.c: #39860 removed the paused-socket probe helper next to the forward declaration this PR adds, so the helper stays removed. Re-verified on the rebased head: the new case, appcontainer.test.ts, udp_socket, deinitialization, node tls and net server suites, and websocket-server on a Windows x64 debug build, and the socket suites on Linux ASAN.

Reproduced on Windows x64 with the new case in test/js/bun/net/socket.test.ts. A data() handler calls socket.terminate() and then expect(Bun.sleep(20)).resolves, which ticks the loop inside the dispatch.

  • Debug build of main (a35696478d): the child process segfaults at 0xFFFFFFFFFFFFFFFF. The frames are us_internal_socket_follow_adopted <- us_internal_dispatch_ready_poll (loop.c:746) <- poll_cb <- uv__fast_poll_process_poll_req (poll.c:233), the same frames as BUN-43AH.
  • Release canary 1.4.0-canary.1+32e87032b: the same fixture crashes too, with a different fault address on each run.
  • With this branch: the case passes on Windows x64 and arm64 debug builds and on a Linux ASAN build. test/bake/deinitialization.test.ts passes on both Windows builds. The unfixed canary hangs on it for 60 s on the same x64 machine.

The first push (876db250) closed the socket before the deferred uv_close. CI caught that with test/js/bun/windows/appcontainer.test.ts on the Windows 11 arm64 lane (exit 0xC0000008): AppContainers run with strict handle checks, and libuv's cancel ioctl hit the closed socket. It failed 10 of 10 runs on an arm64 machine and passed 5 of 5 there with main's libuv.c. f91d44edb8 closes the socket right after the uv_close instead (us_internal_poll_close_fd), which is the order libuv always had. The test passes 10 of 10 on arm64 and passes on x64, and it now has a step for this order. Details are in the PR notes.

No libuv patch. The uv_close for a poll whose callback is on the stack is issued by the outermost poll_cb frame instead.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and the automated bug-hunt found no issues. Because it reworks lifetime/ownership of uv_poll_t blocks in the Windows usockets backend — memory-safety-critical native code with subtle ordering across nested uv_run — a human look is still worthwhile.

What was reviewed:

  • Traced the released/uv_closed hand-off across all us_poll_stop/us_poll_free call sites and the us_poll_start_rc init-failure path — each block is freed exactly once on every path.
  • Checked us_poll_resize: the memcpy carries poll_cb_depth/stopped to the new block and poll_cb re-reads p->data after dispatch, so the decrement and deferred uv_close land on the live block; the old block's stale counter is unreachable (uv_p == NULL → fast-path free).
  • Confirmed us_internal_loop_data_t layout is unchanged (comment-only edit), so the Rust mirror needs no update; us_poll_t has no Rust mirror.
  • The early-return in poll_cb before the depth increment runs no JS, so it cannot race us_poll_stop.
Extended reasoning...

Overview

This PR fixes a Windows-only use-after-free / double-free in the usockets libuv backend when a socket handler re-enters the event loop (e.g. expect(promise).resolves, auto-install). It touches packages/bun-usockets/src/eventing/libuv.c (poll lifecycle: poll_cb, us_poll_stop, us_poll_free, us_poll_start_rc, close_cb_free_poll, us_loop_run/us_loop_pump), adds four state fields to us_poll_t in libuv.h, updates a comment in loop_data.h, and adds a subprocess regression test to test/js/bun/net/socket.test.ts.

The mechanism: (1) count tick_depth on the libuv backend so us_internal_loop_post defers freeing closed sockets past nested ticks (POSIX parity); (2) defer uv_close on a poll until the outermost poll_cb frame for that handle unwinds, so libuv's outer uv__fast_poll_process_poll_req never sees a handle whose endgame already ran; (3) replace the old data = NULL handshake between us_poll_stop and close_cb_free_poll with two explicit bits (released, uv_closed) so whichever runs second frees both blocks — the old scheme leaked when the callback ran first.

Security risks

None user-facing. This is internal event-loop plumbing; no untrusted input parsing, no auth/crypto. The risk class is memory safety (UAF, double-free, leak) in a hot path that every Windows socket goes through.

Level of scrutiny

High. Per the repo's review guidance, native memory safety is the most-blocked category. This change rewrites the ownership contract between two heap blocks (us_poll_t and its uv_poll_t) across three release points (us_poll_stop, us_poll_free, close_cb_free_poll) and a re-entrant uv_run. The reasoning in the PR description is thorough and the code reads correctly to me, but the failure mode of a mistake here is heap corruption on every Windows user's machine, and one defensive branch (the uv_poll_init_socket half-init failure path) was reasoned about rather than exercised. That warrants a maintainer's eyes.

Other factors

  • I traced every us_poll_stop/us_poll_free/us_poll_start_rc caller in loop.c, socket.c, context.c, and udp.c; each follows create → start → stop → free, matching the new one-way stopped contract. The us_poll_start_rc "already registered" branch and us_poll_change after stop are defensive with no current caller (as the PR notes).
  • us_poll_resize interaction checks out: memcpy copies the new fields to the replacement block, p->data is repointed, and poll_cb re-reads it after dispatch; the old block's uv_p = NULL sends its later us_poll_free down the fast path so its stale poll_cb_depth is never consulted.
  • loop_data.h change is comment-only; the Rust mirror in src/uws_sys/InternalLoopData.rs needs no update. No Rust mirror of us_poll_t exists.
  • Test follows harness conventions (subprocess, tempDir, bunEnv, drains both pipes concurrently, asserts a combined object). The 20 ms Bun.sleep inside the fixture is the deliberate mechanism (drive nested ticks), not a wait-for-condition, and is commented as such.

@robobun

robobun commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:07 PM PT - Aug 21st, 2026

@robobun, your commit 174085f has 1 failures in Build #103029 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 39643

That installs a local version of the PR into your bun-39643 executable, so you can run:

bun-39643 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it redesigns the two-block ownership handoff for every Windows socket close (a memory-safety-critical path where getting it wrong trades one UAF for another) and introduces a five-bit state machine (stopped/close_fd/uv_closed/released/poll_cb_depth), a human look would still be worthwhile — particularly to sign off on this shape versus the earlier libuv-patch approach from #33018.

Checked: us_poll_resize copies poll_cb_depth via memcpy before p->data is repointed, so the epilogue's re-read lands on the right counter; us_socket_detach (stop without close_fd) keeps the fd open through the deferred uv_close; the us_poll_start_rc init-failure path composes with the caller's us_poll_free via released; the four bsd_close_socket(us_poll_fd(...)) sites that follow a stop are all converted, and the remaining one in loop.c:862 is epoll-gated.

Extended reasoning...

Overview

This PR fixes Windows crashes (BUN-43AH/442Y/442Z, deinitialization.test.ts) caused by nested uv_run freeing a socket the outer dispatch still holds, and by uv_close running under an outer uv__fast_poll_process_poll_req frame that then queues the endgame twice. The fix has three parts: (1) us_loop_run/us_loop_pump now count tick_depth so us_internal_loop_post defers the closed-socket free to the outermost tick, matching POSIX; (2) a per-poll poll_cb_depth counter defers uv_close to the outermost poll_cb frame's epilogue, so libuv sees a supported close-from-inside-callback instead of a close from a nested run; (3) the old data = NULL handshake between us_poll_stop/us_poll_free/close_cb_free_poll is replaced by explicit released/uv_closed bits so whichever runs second frees both blocks (the old scheme leaked when the callback ran first). A new us_internal_poll_close_fd helper defers closesocket past the deferred uv_close so AppContainer strict-handle-checks don't kill the process on the cancel ioctl. Call sites in context.c, socket.c, udp.c are updated; epoll_kqueue.c gets a pass-through implementation.

Security risks

None identified. This is internal event-loop lifecycle management with no user-controlled input reaching the changed paths.

Level of scrutiny

High. This is the most-blocked category in Bun's review guide (native memory safety): every acquisition/release pairing on the Windows socket-close path is being reorganized, and the failure mode of a mistake is exactly the UAF/double-free class being fixed. The state machine has five interacting bits plus a depth counter, and correctness depends on invariants that span us_poll_stopus_internal_poll_close_fdpoll_cb epilogue → close_cb_free_pollus_poll_free, with us_poll_resize cutting across. The PR description is exceptionally thorough (symbolized fail-before, mechanism for each Sentry shape, ordering rationale, defensive-branch inventory) and the author ran the relevant Windows suites, but the changed code cannot be exercised on this Linux environment and the design choice (per-poll depth counter + deferred close vs. #33018's libuv patch) is one a maintainer should ratify.

Other factors

I traced the us_poll_resize interaction: memcpy copies poll_cb_depth after the outer frame's increment, and the epilogue re-reads p->data to land on the relocated block, so nested dispatch + adopt + close balances correctly. us_socket_detach correctly does not set close_fd, so the fd survives the deferred uv_close for handoff. The one remaining us_poll_stop not followed by us_internal_poll_close_fd at loop.c:862 is under #ifdef LIBUS_USE_EPOLL. The us_poll_start_rc early-return for an already-registered handle and the us_poll_change guard on stopped are defensive (no current caller per the PR notes) but harmless. The new socket.test.ts case is a spawned crash repro with a snapshot on {stdout, exitCode}; the appcontainer.test.ts addition correctly notes the exit code is the real assertion. The PR notes that the uv_poll_init_socket failure path was reasoned about but not run (needs a fault-injection build) — that path now routes through us_poll_stop, which is a behavior change worth a maintainer's eye.

@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Data point from the test/bake/deinitialization.test.ts failures on the Windows lanes (35 of the last 400 builds). They are this bug.

Symbolized CI crash. Build 101365 (Windows x64, 1c8d14b8f, Segmentation fault at address 0x0), symbolized with that build's bun-profile.pdb:

us_internal_socket_after_resolve        packages/bun-usockets/src/context.c:755   (c->group is NULL, c->closed is 0)
us_internal_drain_pending_dns_resolve   packages/bun-usockets/src/loop.c:349
us_internal_loop_post                   packages/bun-usockets/src/loop.c:440
uv__check_invoke / uv_run
TestCommand::run

The crash comes right after the closeActiveConnections sendAnyRequests websocket=1 case passes. The us_connecting_socket_t on the ready list belongs to the next case (websocket=8, eight connects to localhost). A stray free of a live block is what the second close_cb_free_poll in this PR's notes produces, and the reduction below crashes at the same point.

How the fixture gets there. The test body resumes inside the HMR client socket's poll_cb (the onopen microtask). The un-awaited expect(fetch(...)).rejects then runs a nested uv_run from that frame. Inside it the plugin calls server.stop(true) and the server closes the connection. The inner run dispatches the same poll again (poll_cb_depth 2) and closes the socket. The fetch rejection comes from the HTTP thread a few iterations later, so the inner run also processes the AFD cancellation and runs the endgame. Both blocks are freed under the outer uv__fast_poll_process_poll_req frame, which then queues the endgame again. Whether the close lands inside the inner run is a race. That is why CI sees it in about 1 of 10 builds per lane, and why the shape varies (0x0, 0xFFFFFFFFFFFFFFFF, Option::unwrap() on a None value, 60 s hangs). A copy of the two fixture cases hangs or crashes with the un-awaited matchers and passes with them awaited.

Reduction without the dev server. The Bun.sleep(100) keeps the inner run going long enough to run the endgame. The console.log calls are part of it: which block the stray free hits depends on the heap layout, and a variant without them passed 3 of 3 runs on the canary.

import { expect, test } from "bun:test";

test("peer closes the socket during a nested tick inside its own poll callback", async () => {
  const server = Bun.serve({
    port: 0,
    fetch(req, server) {
      if (server.upgrade(req)) return;
      return new Response("no");
    },
    websocket: { message() {} },
  });

  const { promise: done, resolve: finish } = Promise.withResolvers<void>();
  const ws = new WebSocket(`ws://localhost:${server.port}/`);
  ws.onopen = () => {
    const closed = new Promise<number>(resolve => (ws.onclose = e => resolve(e.code))).then(code =>
      Bun.sleep(100).then(() => code),
    );
    setTimeout(() => server.stop(true), 10);
    // Not awaited on purpose: runs a nested event loop tick inside this poll callback.
    expect(closed).resolves.toBe(1006);
    console.log("nested tick returned, readyState =", ws.readyState);
    finish();
  };
  await done;
  console.log("onopen returned");

  // Like the fixture's next case: allocate a batch of fresh connecting sockets.
  await using server2 = Bun.serve({
    port: 0,
    fetch(req, server) {
      if (server.upgrade(req)) return;
      return new Response("no");
    },
    websocket: { message() {} },
  });
  const opens: Promise<void>[] = [];
  const sockets: WebSocket[] = [];
  for (let i = 0; i < 8; i++) {
    const { promise, resolve, reject } = Promise.withResolvers<void>();
    const s = new WebSocket(`ws://localhost:${server2.port}/`);
    s.onopen = () => resolve();
    s.onclose = () => reject(new Error(`socket ${i} closed before open`));
    sockets.push(s);
    opens.push(promise);
  }
  await Promise.all(opens);
  console.log("all 8 follow-up sockets opened");
  const closes = sockets.map(s => new Promise<void>(r => (s.onclose = () => r())));
  for (const s of sockets) s.close();
  await Promise.all(closes);
});

Results on one Windows x64 machine:

build result
canary 1.4.0-canary.1+32e87032b 4 of 4 runs crash, exit code 3: two at 0x0, two at 0xFFFFFFFFFFFFFFFF
debug build of main cfa9f8e15b 3 of 3 runs crash at 0xFFFFFFFFFFFFFFFF
the same debug build with this PR merged 5 of 5 runs pass
the same build, test/bake/deinitialization.test.ts 10 of 10 runs pass (the unmodified canary hangs on it, 2 of 2 runs)

This reduction closes the socket from a second poll_cb frame on the same handle. The test in this PR closes it from the first frame. It may be worth adding next to it.

alii added a commit that referenced this pull request Aug 20, 2026
@alii

alii commented Aug 20, 2026

Copy link
Copy Markdown
Member

For reference from #39488: the test/bake/deinitialization.test.ts Windows x64 segfaults on that branch (builds 101180 and 101478, both at the start of the websocket=8 case) symbolise to heap corruption in the client us_socket_t size class surfacing under us_internal_drain_pending_dns_resolve, i.e. the family this PR fixes. I briefly carried the tick_depth-only subset there and backed it out in favour of this landing whole.

@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, that shape fits. The second close_cb_free_poll frees whatever block the free-list link in the dead uv_poll_t points at, so the stray free lands on a live block of the same size class, and the next user of that block crashes. A us_connecting_socket_t or client us_socket_t on the DNS ready list is one of the first users in that fixture. tick_depth on its own does not stop that: it keeps the outer dispatch from reading freed memory, but the inner run still finishes the close and the outer frame still queues the endgame again. So backing the subset out was right, and this PR has to land as one piece.

Current state: f91d44edb8. The first push closed the socket before the deferred uv_close, and appcontainer.test.ts caught it on the Windows 11 arm64 lane (strict handle checks are on inside an AppContainer, so libuv's cancel ioctl on the closed socket ended the process). The close paths now close the socket right after the uv_close, the order libuv had before. Build 101247 on this head is green on every lane, and deinitialization.test.ts passes on the x64 and arm64 debug builds here.

@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Release build evidence for the test/bake/deinitialization.test.ts part of this PR. The Windows failure of that test was handed to me as a separate bug (seen on #34654, Buildkite builds 99642 and 101711, Segmentation fault at address 0x0 after the first 8 cases pass). It is this bug, and this PR fixes it. I am not opening a second PR.

A/B on one machine (Windows Server 2019 x64, CPU without AVX, so the build is the baseline variant like the CI lane). Both binaries are local --profile=release builds from the same tree and toolchain. The only difference is this PR's diff (f91d44edb8 merged onto main 01c4e2fd6d). Each run is the child fixture, bun test ./test.ts in test/bake/fixtures/deinitialization, with the env that bunEnv sets.

  • main 01c4e2fd6d without this PR: 8 of 8 runs fail. 6 runs hang with the main thread at 100% CPU. 2 runs die with panic(main thread): Segmentation fault, at address 0x0 once and 0x270 once. Every run fails after the 8th (pass) line, in the flags: websocket=8 case. The CI canary of the same commit (1.4.0-canary.1+01c4e2fd6) fails 6 of 6 runs the same way (5 hangs, 1 segfault at 0x26C).
  • main 01c4e2fd6d with this PR: the fixture passes 20 of 20 runs (10 tests each, exit 0). The outer test/bake/deinitialization.test.ts passes 3 of 3 runs.

The PR body lists a debug build for this test. The numbers above cover the release build, which is the build that hangs or crashes on the CI lanes.

@robobun

robobun commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator Author

Verified this branch (f91d44edb8) against one more signature of the same bug: BUN-4MNY, a Windows x64 crash in ssl_retry_parked_write at the tail of us_internal_ssl_on_data (openssl.c:2423), reported on 1.4.0.

Repro: a Bun.connect client writes in open(), calls terminate() in its first data(), and then runs a nested loop from that handler (Bun.build() with a plugin whose setup() returns a promise that settles after 20 timer hops). On a Windows x64 debug build of main (01c4e2fd6d) it segfaults at 0xFFFFFFFFFFFFFFFF on every run: the TCP variant in us_internal_socket_follow_adopted under loop.c:746, the TLS variant in ssl_flush_pending_session under openssl.c:2429, the same tail as the Sentry stack. On this branch both variants pass 15 of 15 runs. The fixture is test/js/bun/net/socket-close-in-nested-loop-fixture.ts on farm/54233a81/libuv-nested-tick-free, in case the TLS tail is wanted as a second case here. The rest of that branch is superseded by this PR.

One note on where tick_depth is counted. us_loop_run and us_loop_pump cover a nested run that Bun enters. An addon that holds the loop from napi_get_uv_event_loop and calls uv_run() from inside a handler (BUN-4MNY has an addon loaded) runs check_cb at depth 1 and frees the closed list. poll_cb_depth still protects the socket whose own callback is on the stack, but not a socket another frame holds, for example an accepted socket closed from its own open() while the accept loop still reads it (loop.c:556). Counting in the callbacks libuv runs for the loop instead (poll_cb, prepare_cb, check_cb, timer_cb, async_cb, with check_cb's own increment making the outermost loop_post depth 1) covers both entry paths, and is a small delta on top of this design.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

State after the merge of main (ca25925ba5): the diff itself is unchanged from f91d44edb8, and build 102732 is running on the merged head.

The notes in the description now also record one limit. An addon that calls uv_run() on this loop from inside a handler is not counted by tick_depth. The socket whose own callback is on the stack is still covered by poll_cb_depth, a socket that another frame holds is not. None of the crashes listed here needed that, so it is left for a follow-up (counting the frames of the callbacks libuv runs for us would cover it).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it redesigns the Windows poll lifecycle state machine (five new lifecycle bits plus poll_cb_depth, deferred uv_close/fd-close ordering, and a new released/uv_closed free handshake) across every socket close path, a human look from someone with libuv/usockets ownership would still be worthwhile.

What was reviewed:

  • The released/uv_closed handshake in close_cb_free_poll/us_poll_free — each block is freed exactly once regardless of which runs first; the old data = NULL leak (#37105) is covered.
  • poll_cb re-reading p->data after dispatch so a mid-dispatch us_poll_resize carries poll_cb_depth/stopped to the new block, and the retired block's uv_p = NULL short-circuits us_poll_stop/us_poll_free.
  • us_internal_poll_close_fd on epoll/kqueue is a no-op wrapper over bsd_close_socket, and the kqueue branch of close_raw (which skips us_poll_stop) still closes immediately there.
  • tick_depth now brackets us_loop_run/us_loop_pump, so check_cbus_internal_loop_post's tick_depth <= 1 gate holds for nested uv_run; no Rust-side layout mirror of us_poll_t exists to fall out of sync.
Extended reasoning...

Overview

This PR fixes a family of Windows-only use-after-free / double-free crashes in bun-usockets' libuv backend that surface when a socket handler drives a nested uv_run (e.g. expect(promise).resolves, auto-install). It touches eventing/libuv.c (the core lifecycle rewrite), internal/eventing/libuv.h (five new bitfields plus poll_cb_depth on us_poll_t), epoll_kqueue.c/internal.h (a pass-through us_internal_poll_close_fd), loop_data.h (comment only), and rewires four close sites in context.c/socket.c/udp.c from bsd_close_socket(us_poll_fd(p)) to the new helper. Two tests are added: a subprocess fixture in socket.test.ts that reproduced the BUN-43AH segfault on main, and an AppContainer step guarding the strict-handle-check ordering.

Security risks

None identified. This is internal event-loop lifecycle plumbing with no user-controlled input reaching the new state; the changes narrow, rather than widen, the window in which freed memory can be touched.

Level of scrutiny

High. This is native memory-safety code that changes when uv_close, the socket closesocket(), and the two heap frees run relative to poll-callback frames and nested uv_runs. The design introduces a small state machine (stopped/close_fd/uv_closed/released/poll_cb_depth) whose invariants must hold across adoption/resize, the uv_poll_init_socket failure path, us_socket_detach, and UDP. The PR body documents each invariant and the CI iteration that caught the fd-before-uv_close ordering (AppContainer 0xC0000008), and the A/B numbers on deinitialization.test.ts are compelling — but the design choices (defer uv_close to the outermost poll_cb frame rather than patching libuv; count tick_depth at us_loop_run/us_loop_pump rather than in every libuv callback) are exactly the kind a maintainer should ratify.

Other factors

  • The bug-hunting pass found nothing. I traced the free protocol in both orders, the resize path (memcpy carries the counter; the retired block's uv_p = NULL makes us_poll_stop/us_poll_free no-ops on it), the kqueue close_raw branch (still closes immediately since epoll/kqueue's us_internal_poll_close_fd is just bsd_close_socket), and us_socket_detach (defers uv_close, leaves close_fd = 0, so the fd survives as intended).
  • us_internal_loop_post at loop.c:446 already gates the closed-list free on tick_depth <= 1; this PR wires the libuv entry points into that counter, matching us_loop_run_bun_tick on POSIX. I confirmed there is no Rust #[repr(C)] mirror of us_poll_t that the added fields would desync.
  • The PR notes an acknowledged limit (native addons calling uv_run directly are not counted by tick_depth) and defers it; that is a design call worth a maintainer's nod.
  • Build 101247 was green on all lanes at f91d44edb8; the diff is unchanged since. alii's comment corroborates this fixes the #39488 Windows failures.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Cross reference: #39910 (opened 2026-08-21, CI green) fixes the same bug. It also counts tick_depth around uv_run in us_loop_run and us_loop_pump, and it also stops us_poll_stop from starting the uv_close. It issues the uv_close from us_poll_free instead of counting poll_cb frames, so it is smaller (+118/-55 against +295/-65 here). Neither description references the other.

I built both heads (063e4c0f7 here, dd7070d64 there) on a Windows Server 2019 x64 machine. The baseline is the canary build of 40ef8113d, the main commit both are based on:

The full comparison is in #39910 (comment). It lists the hunks and tests in this PR that #39910 does not have: the us_internal_poll_close_fd close order and the poll_cb_depth it depends on, the released / uv_closed bits (only this design needs them), the defensive guards after a stop, the two socket.test.ts cases and the appcontainer step, and the Sentry groups. #39910 has a fixture that closes from the client side with allocation churn, which this PR does not have. Both PRs also cover #37105.

I am not closing either PR. One of the two should land and the other should close. If #39910 lands first, the close order hunks and the appcontainer step from here should follow it as a rebased follow-up, or AppContainer processes regress from main.

…libuv are done with it

The libuv backend never counted tick_depth, so a nested tick (a handler
that waits for a promise) freed the socket that the outer dispatch was
still reading. The nested uv_run also ran the endgame of that socket's
uv_poll_t under libuv's outer uv__fast_poll_process_poll_req frame, which
then queued the endgame again, and close_cb_free_poll freed both blocks
twice.

us_loop_run and us_loop_pump now count tick_depth like the POSIX backend.
While a poll_cb frame for a poll is on the stack, us_poll_stop only
disarms the handle; the outermost frame issues the uv_close on its way
back into libuv and then closes the socket, which the close paths hand
over through us_internal_poll_close_fd (uv_close cancels the in-flight
request with an ioctl on the socket, and a process with strict handle
checks, such as an AppContainer, dies if the socket is already closed).
us_poll_free and close_cb_free_poll record which of them ran first and
the second one frees both blocks, which also covers a close callback that
runs before us_poll_free, a us_poll_free without us_poll_stop, and
us_poll_start_rc or us_poll_change on a handle libuv still references.
@robobun
robobun force-pushed the farm/47550167/win-poll-free-protocol branch from 063e4c0 to 174085f Compare August 22, 2026 00:54

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and the automated bug hunt found no issues. Given the scope — a redesign of the libuv poll lifecycle (deferred uv_close via poll_cb_depth, the released/uv_closed two-block hand-off, deferred fd close for the AppContainer ordering) — a human look is still warranted, and there's a competing design in #39910 to decide between.

Checked: the four bsd_close_socketus_internal_poll_close_fd call sites all follow us_poll_stop (or its kqueue equivalent), so the epoll/kqueue path is a straight rename. us_poll_resize copies the new bitfields and poll_cb_depth into the replacement block, and poll_cb re-reads p->data after dispatch, so a mid-dispatch adopt keeps the counter and deferred-close state consistent. The h->type != UV_POLL guard in us_poll_stop/us_poll_free relies on us_create_poll's new us_calloc zeroing, which is in place.

Extended reasoning...

Overview

This PR reworks the Windows/libuv poll lifecycle in packages/bun-usockets to fix a family of use-after-free crashes (BUN-43AH, BUN-442H/Y/Z, BUN-4NC8, BUN-4NP3, and four TLS variants) that occur when a socket handler synchronously waits for a promise, which re-enters uv_run while the outer uv__fast_poll_process_poll_req frame still holds the handle. The fix has three interlocking pieces: (1) tick_depth is now counted around uv_run in us_loop_run/us_loop_pump so us_internal_loop_post defers freeing the closed list to the outermost tick (POSIX parity); (2) us_poll_stop only disarms the handle while poll_cb_depth > 0, and the outermost poll_cb frame issues uv_close on its way back into libuv, which is the reentrancy shape libuv supports; (3) us_poll_free and close_cb_free_poll coordinate via released/uv_closed bits so whichever runs second frees both blocks. A new us_internal_poll_close_fd helper defers the socket close until after the deferred uv_close is issued, preserving the order libuv's cancel ioctl needs (AppContainers with strict handle checks die otherwise — caught by CI on the first push). Four call sites in context.c/socket.c/udp.c are rewired through the helper; on epoll/kqueue it is a straight bsd_close_socket wrapper.

Security risks

None identified. This is event-loop lifecycle and memory-ownership code, not input parsing or auth/crypto. The change closes UAF/double-free bugs rather than introducing new attack surface.

Level of scrutiny

High. This is native C in the event loop, the "memory safety (the most-blocked category)" section of the review rules. It redesigns two-block ownership across an asynchronous libuv close callback, adds five state fields to us_poll_t, and changes when uv_close and closesocket run relative to nested uv_run frames. The PR description is exceptionally thorough (symbolized fail-before, A/B on release builds, Sentry group mapping, the AppContainer regression it caught and fixed mid-PR), and CI is green on the merged head. But the design has an acknowledged limit (native addons calling uv_run directly), one defensive path was "reasoned about, not run" (the fault-injection failure branch), and #39910 offers a smaller competing design that this PR's author has A/B-tested and shown to regress the AppContainer case — that trade-off is a design decision a maintainer should make.

Other factors

  • No prior human review on the thread; the only non-bot comment (from alii) is a cross-reference confirming this fixes their branch's crashes too.
  • Tests: a new subprocess-spawning case in socket.test.ts that reproduces the crash on unfixed builds (verified in the PR notes), and a new closeInHandler step in appcontainer.test.ts that guards the socket-close ordering. Both are Windows-relevant and cannot run on this reviewer's platform.
  • The us_poll_t struct grows by ~5 bytes of bitfields plus an unsigned int; us_create_poll initializes all five new fields explicitly, and us_poll_resize's memcpy carries them to the replacement block.
  • I traced us_poll_resize against the new poll_cb epilogue: the epilogue re-reads wp = p->data after dispatch, and resize sets p->uv_p = NULL on the retired block while pointing uv_p->data at the new one, so the deferred uv_close and close_fd land on the live block. The retired block's own us_poll_free (from us_internal_free_closed_sockets) takes the !h fast path.
  • The h->type == UV_POLL re-start guard in us_poll_start_rc and the p->stopped guard in us_poll_change are noted as having no current caller; they are defensive and look correct against the state machine described in the header comments.

@robobun

robobun commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator Author

For whoever merges: #39910 took the same two steps (tick_depth, and no uv_close from under the poll callback) but issued the uv_close from us_poll_free, after close_raw has closed the socket. That is the order the AppContainer lane rejects (strict handle checks make libuv's cancel ioctl on the closed socket fatal, see the notes above), so this PR issues it from the outermost poll_cb frame and closes the socket right after, the order main has. The side by side is in the #39910 thread, and that PR is now closed.

Build 103029 on the rebased head 174085f6ee: the only red test is test/cli/install/bun-audit.test.ts, which fails on main since the 1.4.1 version bump (normalizeBunSnapshot rewrites the <1.4.1 range in the mime advisory) and is reported for a fix there. Everything else passed, or passed on retry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants