Skip to content

usockets: close a socket whose resume the kernel refuses from the loop, not from the caller - #43738

Open
robobun wants to merge 5 commits into
mainfrom
robobun/8454b6f1/serve-resume-close-uaf
Open

robobun wants to merge 5 commits into
mainfrom
robobun/8454b6f1/serve-resume-close-uaf

Conversation

@robobun

@robobun robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • us_socket_resume() closes the socket when the kernel refuses its poll registration (epoll_ctl, ENOMEM or ENOSPC). It dispatched that close on the caller's stack: uWS destructs the response state and the abort handler frees Bun's RequestContext, so the caller works on freed state.
  • Three crashes in Bun.serve with the failure injected: use-after-poison in RequestContext::live_resp (from on_start_buffering), a JSC::WeakImpl SEGV (from PendingValue::set_promise), and panic: assertion failed: this.resp.get().is_some() in on_abort (from detach_response).
  • The close is from usockets: defer eof for a paused socket that already sent FIN; stop backpressure pauses from holding the loop #33974 (socket.c, the us_poll_change branch) and it is needed: nothing else delivers that socket's tail, end or close.

Fix

  • us_socket_resume() lists the socket with its close code and wakes the loop. us_internal_loop_post() closes it, so the close never runs inside resume(). The list drops every address the socket free invalidates, and follows a socket us_socket_adopt() moved.
  • Correct because only the close waits. The socket keeps its fd and its state, so a caller that writes, pauses or ends meanwhile behaves as before, and nothing can arrive while the kernel does not poll it.
  • Review: 5 findings, 4 addressed. The close now runs at every tick depth (a tick that a callback started could wait for it without end), the test now proves that the injected failure fired, its cases run on the default timeout, and a fixture that crashes leaves no socket file. The fifth is a kqueue case that main has too (Notes).
  • Verified: four cases in serve-syscall-fault.test.ts, each failing on main in 3 of 3 runs. Also the Bun.serve, node:http, node:net and fetch suites.

Background

Notes

The reproduction. bun:internal-for-testing's socketFaultInjection has a poll_start rule for this path, which is unreachable otherwise; it is compiled in for ASAN builds. A unix socket, because the close of its peer raises the hangup at once (a TCP peer has to take both directions down). The client sends more than REQUEST_BODY_HIGH_WATER_MARK and closes, the server pauses, the dispatcher parks the fd, then the handler's body read or response end resumes it. The fixture makes one /ping request before it arms the fault, which is what makes the loop poll and park the fd. epoll only: kqueue and libuv never park the fd, so their resume is a plain filter or poll change with nothing to fail. With the fixtures as of 5d8eca9, 12 of 12 runs fail on the base build (four fixtures, three runs each) and none fails with this change. The two commits after it change how the cases run and where the socket file lives, not what the fixtures do, and were run on the build with the change only.

The proof that the fault fired (review finding). The fixture makes a second /ping after the handler: a one-shot rule that the resume did not consume fails the first poll registration of that connection. The first version used fetch() for it, which reuses a unix connection and so registers nothing. With the rule armed, a second fetch() returned 200 and a net.connect() of its own failed with ECONNREFUSED. Both pings are now a connection of their own. That showed a second problem: in the response-ends mode the last ping ran before the response ended, consumed the rule itself, and the resume never failed (after: error ECONNREFUSED). On main that mode had crashed only because the reused connection left the rule for the resume. The fixture now waits until server.pendingRequests is 0, which is after the resume in every mode.

The close runs at every tick depth (review finding). The first version closed the listed sockets under the same tick_depth <= 1 condition as the free, on the reasoning that the owner that resumed the socket can be on the outer stack. That reasoning does not hold: a frame below an inner tick has yielded to the loop, the dispatch of that inner tick closes sockets at that depth already, and so can any JavaScript that runs there. What a caller cannot take is a close inside resume() itself. With the condition, a tick that a callback started could wait for that close without end. serve-resume-fault-nested-fixture.ts covers it: the resume fails inside the dispatch of another request, which then waits on the loop with an expect().rejects that is not awaited. With the condition in place that test timed out at 5000 ms.

The test timeout (review finding). The second push gave the four cases a 30 s timeout, with a comment about the time a sanitizer report takes to symbolize. That comment did not give the real reason. The real reason was the passing path: run together, the four debug + ASAN processes took 3.8 s to 4.8 s each on a loaded machine (load average about 130), against the 5 s default. test/CLAUDE.md says not to set a timeout on tests. The cases now run one at a time on the default, like the other cases of that file: 1.9 s to 3.5 s each on the same machine, 12 of 12 passes over three runs.

The socket file and the shutdown (review finding). The fixtures built their unix socket path by hand in the shared tmpdir and removed it on the success path only, so a fixture that crashed left it behind: the runs on the unfixed build for this PR had left 37 of them. The test now owns a tempDir and hands the path to the fixture, so the cleanup does not depend on how the fixture ends. Both fixtures also stopped the server twice, by hand and through using. The graceful stop() is the one that matters (it resolves once every connection is gone, the failed one included), so it stays and using goes.

Follow-up, not in this PR (review finding, also on main). On kqueue, us_poll_change() discards the result of kqueue_change(), so a refused EV_ADD leaves a resumed socket open and deaf on macOS where epoll now closes it. Returning that result would make us_socket_resume() list the socket there too, with no other change. It is left out because it changes which sockets close on macOS and it could not be run here: no fault rule reaches that path (poll_start is in us_poll_start_rc(), which the kqueue us_poll_change() never calls), and kqueue_change() returns 1 for every error event that is not the ENOENT of a delete, so each errno an EV_ADD on a live socket can report needs a look first.

The automated self-review did not complete. It was started three times and was cut off each time before it reported. The review findings above come from the automatic reviewer on this PR.

The crashes in full, from the fixtures:

  • req.arrayBuffer() on a paused body: AddressSanitizer: use-after-poison, READ of size 8, RequestContext::live_resp (RequestContext.rs:4283) from on_start_buffering (:4363). The poisoned read is the freed pool slot of the context. The nested fixture dies the same way.
  • req.text() on a materialized body: SEGV in JSC::WeakImpl::state(), from readable_stream::Strong's drop inside PendingValue::set_promise (Body.rs:435).
  • A response that ends while the body is paused: panic: assertion failed: this.resp.get().is_some() in on_abort (RequestContext.rs:1460), reached from detach_response through end_without_body. A release build has no assertion there and releases the context twice instead.

Why not report the closure out of resume(). The first shape returned the verdict through HttpResponse::resume() and uws_res_resume() into AnyResponse::resume() -> bool, with a bail-out per call site. That needs a correct bail-out in about ten places (detach_response alone is reached from twelve), each of which must also keep the context alive across the call to read its own state afterwards, and it leaves the next caller exposed. detach_response additionally had to disarm the abort handler before the resume, since an abort must not fire for a request that is being ended. The req.text() crash is in Body.rs, three frames above the resume, so the bail-outs do not stop at the callers either. Deferring the close removes all of that.

The sites this covers, all on main, all of which resume and then keep using the response or the context:

  • src/runtime/server/RequestContext.rs: on_start_buffering, on_buffered_body_chunk (three resumes), on_request_body_stream_drained, detach_response.
  • src/runtime/webcore/streams.rs: HTTPServerWritable::mark_response_ended.
  • packages/bun-uws/src/HttpResponse.h: HttpResponse::resume() calls resetTimeout(), which reads idleTimeout out of the destructed ext block.
  • src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp, JSNodeHTTPServerSocketPrototype.cpp: the node:http flood-prevention resumes.
  • packages/bun-usockets/src/crypto/openssl.c: the resume in the TLS adopt, whose comment already says that no dispatch may happen there, because the caller has not repointed the ext slot yet.

Not changed, and one edge. us_socket_pause() ignores the return value of its own us_poll_change(). A pause the kernel refuses only means reads keep flowing, which costs the backpressure and nothing else. The edge: us_loop_run_bun_tick() returns at once when no poll refs the loop, so a listed socket that its owner unref'd, in a process with nothing else alive, is not closed before that process exits. Before, its close event ran inside resume().

Not covered by a test. A socket that is adopted, or a loop that is torn down, between the failed resume and the close (the pruning in us_internal_free_closed_sockets() is for those). The other owners of a resumed socket (the fetch client, the WebSocket client, the SQL clients) get the same deferred close, but only Bun.serve and the existing node:net case in socket-syscall-fault.test.ts run under the injected failure.

Suites, debug + ASAN. serve.test.ts, bun-server, bun-serve-file, request-smuggling, serve-close-delimited-framing, serve-pending-promise-abort-leak, node-http, node-http-backpressure, node-http-server-socket-end-drain, socket.test.ts, node-net, fetch-backpressure, fetch.stream, serve-syscall-fault, socket-syscall-fault, node-http-syscall-fault, fetch-syscall-fault, ws-syscall-fault, websocket-syscall-fault, tls-syscall-fault, socket-fault-injection, and upstream test-http-pause, test-http-pause-no-dump, test-http-pause-resume-one-end, test-net-write-slow. After the review changes: serve-syscall-fault and socket-syscall-fault again, and the node:net parked-resume case 10 of 10 on its own.

Environment, not from this change. serve.test.ts's root range port and /bun:info loopback cases fail the same way on the released build here (the tests run as root). node-net.test.ts's reused-handle leak case spawns 16,000 connects and exceeds its 60 s budget under ASAN. node-http-syscall-fault.test.ts's TLS proxy case needs 6.0 s against the 5 s default and passes with --timeout. After the review changes the host was under load (load average about 130): in 2 of 5 runs of the two fault files together, cases with the 5 s default timed out. In the run whose output was kept they were Bun.listen and the parked resume of socket-syscall-fault.test.ts, together, and the Bun.listen case never resumes a socket. Three more runs of that file passed, with those two cases at 2.2 s to 4.9 s, and the parked resume takes 2.4 s to 2.9 s on its own.

Related. Found during the work on #38128, which guards only the two resumes it adds; with this change those guards never trigger. #41384 holds a tick level across close_all so a nested tick frees nothing, and #37099 changes what us_socket_pause() arms. Both touch these functions and conflict textually at most.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/http/serve-syscall-fault.test.ts

…p, not from the caller

us_socket_resume() closes the socket when the poll registration it needs
fails (#33974). The close dispatched on_close from inside resume(), which
runs from the middle of an owner's work: uWS destructs the HttpResponseData
and Bun's abort handler frees the RequestContext that work still uses.

The loop now owns that close. us_socket_resume() lists the socket with the
close code and wakes the loop, and us_internal_loop_post() closes it at the
same point it frees closed sockets, where no owner frame is on the stack.
The list drops every address the socket free invalidates, and follows a
socket us_socket_adopt() moved.
@robobun

robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on the debug + ASAN build of main with the existing socketFaultInjection poll_start rule: a Bun.serve request over a unix socket, the client sends more than 1 MiB of body and closes, the dispatcher takes the paused fd out of the epoll set, and the next resume fails its registration. Four fixtures fail on main in 3 of 3 runs each: req.arrayBuffer(), req.text() on a materialized body, a 304 sent while the body is paused, and a dispatch that waits on the loop for the socket it resumed. All four pass with this change.

Run it: bun bd test test/js/bun/http/serve-syscall-fault.test.ts

Review. The automatic review raised 5 findings. Four are addressed. In 5d8eca9: the close now runs at every tick depth, and the fixture now proves that the injected failure fired (that also exposed a wrong order in the response-ends mode, now fixed). In 6555c56: the four cases run one at a time on the default timeout. The 30 s timeout of the push before it had a comment that did not give its real reason, which was the time of the passing path on a loaded machine. In c5c8191: the unix socket lives in a tempDir of the test, so a fixture that crashes leaves no file, and each fixture stops its server once. The fifth finding is a kqueue case that main has too. It is left as a follow-up, with the reasons in its thread and in the notes of the PR body.

The fail-before runs (12 of 12 on the base build) used the fixtures as of 5d8eca9. The two commits after it do not change what the fixtures do, and were run on the build with the change only.

The automated self-review did not complete. It was started three times and was cut off each time before it reported, so there is no result from it. These points were checked by hand, by reading the code:

  • us_socket_adopt() leaves the old allocation on closed_head with adopted set and prev pointing at the new socket, and us_poll_resize() does not free it. The list follows that link before the free.
  • Teardown: drain_closed_sockets() (src/jsc/VirtualMachine.rs, src/jsc/rare_data.rs) calls us_internal_free_closed_sockets() outside loop_post, which is why the list is pruned in that function.
  • Every other us_poll_free() call frees a poll that no owner received, so only the closed-list free can invalidate a listed address.
  • Layout: the C struct and InternalLoopData get the same two fields at the end. bun run rust:check-all passes on 12 of 12 targets. No assertion compares the C size with the Rust size, so the Windows layout rests on the Windows lanes of CI.
  • kqueue and libuv: their us_poll_change() always returns 0, so the new branch is reachable on epoll only.

Not covered by a test: a socket that is adopted, or a loop that is torn down, between the failed resume and the close. The other owners of a resumed socket (the fetch client, the WebSocket client, the SQL clients) get the same deferred close, but only Bun.serve and the existing node:net case in socket-syscall-fault.test.ts run under the injected failure.

@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: ea56ad5c-6ba1-455c-a23d-d1f43d9581bf

📥 Commits

Reviewing files that changed from the base of the PR and between 2f9bf19 and d1e4b44.

📒 Files selected for processing (7)
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/internal/loop_data.h
  • packages/bun-usockets/src/loop.c
  • packages/bun-usockets/src/socket.c
  • src/uws_sys/InternalLoopData.rs
  • test/js/bun/http/serve-resume-fault-fixture.ts
  • test/js/bun/http/serve-syscall-fault.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 9 remain after this review.


Walkthrough

The change defers closure of sockets whose poll registration fails during us_socket_resume(). Loop state tracks these sockets and closes them during outermost loop processing. Linux HTTP tests cover buffered, streamed, and response-ending request paths.

Changes

Resume failure handling

Layer / File(s) Summary
Unresumable socket state
packages/bun-usockets/src/internal/loop_data.h, src/uws_sys/InternalLoopData.rs
Loop data now stores unresumable socket records, close codes, and list counts.
Deferred closure processing
packages/bun-usockets/src/internal/internal.h, packages/bun-usockets/src/loop.c, packages/bun-usockets/src/socket.c
Failed resume registration queues the socket for loop-level closure. Outermost loop processing closes valid entries and removes closed or adopted entries.
Resume failure fault coverage
test/js/bun/http/serve-resume-fault-fixture.ts, test/js/bun/http/serve-syscall-fault.test.ts
Linux subprocess tests inject poll_start failure and cover buffered bodies, streamed bodies, and responses that end while the body is paused.

Suggested reviewers: cirospaciari

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to d1e4b

Failed socket resumes are now closed during loop processing rather than on the caller’s stack, preventing the reported request-state crashes. The targeted fault paths pass, so this change is mergeable with normal checks.

🚥 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 main change: failed socket resumes now close from the event loop instead of the caller stack.
Description check ✅ Passed The description is complete and directly related to the change. It explains the problem, fix, verification results, background, and known limitations. It does not use the exact template headings, but …

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

@robobun

robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:10 AM PT - Sep 22nd, 2026

✅ @robobun, your commit c5c81912a19c7b50b422db4107cfbadac8a0382d passed in Build #119611! 🎉


🧪   To try this PR locally:

bunx bun-pr 43738

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

bun-43738 --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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked the C/Rust mirror of us_internal_loop_data_t field-by-field (int tick_depth → int num_unresumable_sockets → pointer, same order and #[repr(C)], so the layout stays in sync), and the drain in us_internal_close_unresumable_sockets (pops one entry per iteration and re-reads the count, so a close handler that lists or closes another socket during the walk is not affected).

Extended reasoning...

The change moves the failed-resume close in usockets from the caller's stack to the loop's post step via a new realloc-grown list on the loop data, with a Rust struct mirror and Linux-only ASAN fault-injection tests. It touches no auth, crypto, or input-parsing surface. Three inline findings were posted (nested-tick drain gap, a test proof gap, and a pre-existing kqueue sibling), so the inline comments already signal that a human look is warranted; this note only records what else was examined.

Comment thread packages/bun-usockets/src/loop.c
Comment thread test/js/bun/http/serve-resume-fault-fixture.ts Outdated
Comment thread packages/bun-usockets/src/socket.c
…t prove the fault fired

us_internal_loop_post() closed the listed sockets only at the outermost
tick. A tick that a callback started can be the one that waits for that
close (expect().rejects from inside a dispatch), and it then never ended.
The close now runs at every depth; only the free waits for the outermost
tick, as before. A frame below an inner tick has yielded to the loop,
whose dispatch closes sockets at that depth already.

The fixture's pings now use a connection of their own. fetch() reuses a
unix connection, which registers no poll, so the ping after the handler
could not see a rule the resume had not consumed. With that fixed, the
response-ends mode showed that its ping ran before the response ended:
it now waits until the server releases the request. A fourth case covers
the inner tick.

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

5d8eca9 addresses both points from the earlier pass (the drain now runs at every tick depth, and the fixture's pings open their own connection). Beyond the inline nit, I also checked the drain loop against a close handler that lists another socket: it copies the entry out and re-reads data->unresumable_sockets each iteration, so a nested us_realloc cannot leave a stale pointer; the array has one owner (us_internal_loop_data_free) and starts zeroed from the loop's calloc. Given this is a new deferred-close lifetime path in uSockets, a human look is still worthwhile.

Extended reasoning...

The change adds a deferred-close list to uSockets loop data (loop_data.h, loop.c, socket.c), a matching Rust struct mirror, and Linux-only fault-injection tests. It touches no auth, crypto, or input-parsing surface. Not approving because it introduces a new socket-lifetime mechanism in native code that interacts with adopt/free ordering, and a verified finding from this run is not being posted.

Comment thread test/js/bun/http/serve-syscall-fault.test.ts Outdated
…timeout

The previous commit gave the four cases a 30 s timeout, with a comment
about the time a sanitizer report takes to symbolize. That was not why
it was added: run together, the four debug + ASAN processes took 3.8 s to
4.8 s each on a loaded machine, against the 5 s default. test/CLAUDE.md
says not to set a timeout on tests.

Run one at a time they take 1.9 s to 3.5 s on the same machine, which is
where the other cases of this file are, so they use the default again.

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/bun/http/serve-resume-fault-fixture.ts
… one shutdown per fixture

A fixture that crashes, which is the failure these cases catch, left its
unix socket file in the shared tmpdir: the path was built by hand and
removed on the success path only. The test now owns a tempDir and hands
the socket path to the fixture, so the cleanup does not depend on how the
fixture ends.

Both fixtures stopped the server by hand and through `using`. The
graceful stop() is the one that matters (it resolves once every
connection is gone), so it stays and `using` goes.

@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 the latest push and found no bugs; the fixture cleanup from the previous round is addressed (one shutdown per fixture, socket under the test's tempDir). Because this changes when usockets closes a socket relative to the caller's stack, a human look at the loop.c lifetime handling would still be worthwhile.

What was reviewed:

  • The deferred-close list against the only socket free site (loop.c us_internal_free_closed_sockets) — entries are pruned or re-pointed through us_internal_socket_follow_adopted before that free, and the drain skips already-closed sockets, so a re-list, a self-close, or an adopt between resume and loop_post does not leave a dangling entry.
  • The every-depth drain in us_internal_loop_post versus the tick_depth <= 1 free — the close only moves the socket onto closed_head; the free still waits for the outermost tick.
  • The Rust mirror in src/uws_sys/InternalLoopData.rs — field order matches the C struct and the existing offset_of!(PosixLoop, num_polls) == size_of::<InternalLoopData>() assert covers the size.
  • The nested fixture's un-awaited expect().rejects — it goes through vm.wait_for_promise, so it is the inner tick the test claims to exercise.
Extended reasoning...

The change touches the vendored usockets C loop (loop.c, socket.c, loop_data.h, internal.h), moving the failure path of us_socket_resume from a synchronous close on the caller's stack to a realloc-grown list drained in us_internal_loop_post, plus the matching Rust layout mirror and four new Linux-only ASAN fault-injection tests. It touches no auth, crypto, or injection surface; the sensitive part is socket lifetime and memory ownership in C. All four of my earlier inline comments were followed by commits that plausibly addressed them (every-depth drain, fresh-connection pings, default timeout, tempDir + single shutdown), and the bug hunt ran dry with no findings. I could not run the tests here (no debug/ASAN build present, and the fault hook is only compiled into ASAN builds), and the change alters ordering of close-handler dispatch in a shared C loop, so I am deferring rather than approving.

This branch has not been deployed

No deployments
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.

1 participant