Skip to content

usockets: keep a socket paused when the TLS handshake queue drains it - #42351

Merged
Jarred-Sumner merged 1 commit into
mainfrom
robobun/33810928/tls-pause-low-prio-drain
Sep 29, 2026
Merged

Jarred-Sumner merged 1 commit into
mainfrom
robobun/33810928/tls-pause-low-prio-drain

Conversation

@robobun

@robobun robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.listen({ tls }): socket.pause() on a connection that waits in the TLS handshake queue does not hold. The queue drain arms the reads again, and handshake and data fire on a socket whose pause() returned. In a burst of 32 connections, all 27 queued sockets get both callbacks (1.4.2, canary, main).
  • The cause is us_internal_handle_low_priority_sockets (packages/bun-usockets/src/loop.c:320). It calls us_poll_change(events | LIBUS_SOCKET_READABLE) for each socket it takes out of the queue and does not check s->flags.is_paused.

Fix

  • The drain links a paused socket back into its group, leaves its reads off, and sets low_prio_state to 0. It is an ordinary paused socket again.
  • us_socket_resume arms the reads. The readable dispatch then limits the socket like any other handshaking socket, so a burst of resume() calls cannot run all the delayed handshakes in one iteration.
  • Verified: new test in test/js/bun/net/socket-syscall-fault.test.ts (fixture tls-pause-parked-handshake-fixture.ts). Also socket.test.ts and the node:tls suites.

Background

  • uSockets reads at most 5 handshaking TLS sockets per loop iteration. A socket over the budget is parked: unlinked from its group, linked into loop->data.low_prio_head, reads off. Each later iteration starts with a drain that takes 5 sockets out and switches their reads on.
  • low_prio_state: 0 is normal, 1 is parked, 2 is taken out by the drain. State 2 is read on its next readable event with no charge to the budget.
  • us_socket_pause switches the readable poll off and sets is_paused. On a parked socket the poll is off already, so only the flag changes. us_socket_resume undoes both.
Notes

Reported from a fuzz run as a two-process script: N TCP connections, then all ClientHellos in one tick, and a control byte in that same tick that makes the server pause every accepted socket. N=40 gave 12 to 30 sockets with callbacks after pause() on 1.4.2, 23 on canary d316760, 15 to 72 of 200 on main 6b394bf (ASAN). N=5 (nothing queues) gave 0 in every run. With this change the same script gives 0 for N=5, 40 and 200.

resume() on a parked socket was handled already: the "already parked" arm in the readable dispatch (loop.c:627). This is the pause() direction.

The fixture is deterministic because server and clients share one loop, so the loop iteration counter (getEventLoopStats().iteration, the counter of us_internal_loop_pre) is an exact clock. It parks sockets with the recipe of tls-close-all-sibling-fixture.ts: pause every accepted socket in open, then resume all of them at once, so the next iteration reads 5 ClientHellos and parks the rest. An immediate of that iteration pauses all 32 again. The loop then turns 36 iterations, several times what the queue needs to drain.

Fixture output, release 1.4.3-canary.1 and a debug+ASAN build of main without the change:

{"opened":32,"whilePaused":{"handshake":27,"data":27},"clientHandshakesWhilePaused":32,"serverHandshakes":32,"serverData":32,"pongs":32,"errors":0}

With the change (20 of 20 runs, 4 at a time):

{"opened":32,"whilePaused":{"handshake":0,"data":0},"clientHandshakesWhilePaused":5,"serverHandshakes":32,"serverData":32,"pongs":32,"errors":0}

clientHandshakesWhilePaused: 5 is the budget: the 5 ClientHellos read before the pause. It also proves that the other 27 sockets were parked when pause() ran. The last three numbers are taken after resume(): every socket that the drain left paused comes back.

A paused socket still pays one unit of the iteration's budget when the drain takes it out, the same as a closed socket in that loop. Other handshakes are then parked for one more iteration and go first in the next drain (the queue is LIFO), so nothing starves.

Related open PRs do not touch the drain: #42285 (App::closeIdle marks parked sockets), #36403 (timeout sweep for parked sockets), #37099 (pause() arms writable interest).

Suites run on a debug+ASAN build: socket-syscall-fault.test.ts, socket.test.ts, tcp-server.test.ts, node-tls-server.test.ts, node-tls-connect.test.ts, node-net-server.test.ts, fetch.tls.test.ts, tls-keepalive.test.ts, and the 245 vendored test-tls-*.js and test-https-*.js files. Failures that are the same without the change: socket.test.ts "should not call drain before handshake" (needs DNS for www.example.com), "an unref'd Bun.listen() ... across GC" (over 5 s on a debug build), node-tls-server.test.ts "SNICallback runs even when..." (localhost resolution in this container), vendored test-https-timeout.js (hangs on a debug build of main as well) and test-tls-client-allow-partial-trust-chain.js (uses describe outside the test runner).


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/net/socket-syscall-fault.test.ts

us_socket_pause on a socket parked in the low-priority handshake queue
finds the reads already off and only sets is_paused. The queue drain
switched the reads back on without looking at the flag, so the handshake
ran and the owner got handshake and data callbacks on a socket whose
pause() had returned.

The drain now relinks a paused socket into its group and leaves its
reads off, with low_prio_state 0: it is an ordinary paused socket again.
us_socket_resume arms the reads and the readable dispatch gates it like
any other handshaking socket.
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 9 days. After that, they cost $0.25 per reviewed file.

Or wait 8 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: a7d685d0-66ec-4735-b3fe-4ab3047fd2e2

📥 Commits

Reviewing files that changed from the base of the PR and between 6a92015 and 82a3ce1.

📒 Files selected for processing (3)
  • packages/bun-usockets/src/loop.c
  • test/js/bun/net/socket-syscall-fault.test.ts
  • test/js/bun/net/tls-pause-parked-handshake-fixture.ts

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

@robobun

robobun commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on release 1.4.3-canary.1 and on a debug build of main 6b394bf with test/js/bun/net/tls-pause-parked-handshake-fixture.ts: 27 of 27 queued sockets get handshake and data after pause(), and all 32 clients complete their handshake while the server sockets are paused.
  • The two-process script from the report gives the same result: N=40, 17 sockets with callbacks after pause(). N=5 (nothing queues), 0.
  • With this PR the fixture reports 0 callbacks while paused and 5 client handshakes (the ones read before the pause). After resume() all 32 connections complete.
  • The second finding from the same report (pause() then end() on a TLS socket never closes) is a different cause and has its own PR: usockets: resume a paused TLS socket when its graceful close waits for the peer #42352.

@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 changes the uSockets low-priority TLS handshake queue state machine — where low_prio_state, group linkage, and poll events must stay consistent for close/resume/timeout — a human look is still worthwhile.

What was reviewed:

  • State after the new paused branch: unlinked from low_prio_head, low_prio_count--, re-linked to group, low_prio_state=0, reads stay off — us_socket_resume re-arms and the readable dispatch re-gates via the budget.
  • Budget accounting: paused sockets still consume one drain slot per iteration, matching the closed-socket branch above it; queue is LIFO so nothing starves.
  • Sibling re-arm sites (socket.c:415, the "already parked" arm at loop.c:634) already respect is_paused; this drain was the remaining gap.
  • Test: polls on getEventLoopStats().iteration with a deadline (no sleeps), port: 0, asserts whilePaused is zero and all 32 come back after resume; the exact clientHandshakesWhilePaused: 5 depends on shared-loop iteration timing and is not Windows-skipped, so worth a CI check on the libuv backend.
Extended reasoning...

Overview

The PR adds a 7-line guard to us_internal_handle_low_priority_sockets in packages/bun-usockets/src/loop.c: when draining a parked TLS-handshake socket back into its group, it now checks s->flags.is_paused and, if set, clears low_prio_state to 0 and skips the us_poll_change(... | LIBUS_SOCKET_READABLE) call. This prevents the drain from silently re-arming reads on a socket whose pause() had already returned. A new test.concurrent case in test/js/bun/net/socket-syscall-fault.test.ts spawns the new fixture tls-pause-parked-handshake-fixture.ts, which parks 27 of 32 TLS sockets, pauses all 32, spins the loop enough to drain the queue, and asserts zero handshake/data callbacks fired while paused and all 32 recover after resume().

Security risks

None identified. The change is a state-machine correctness fix in the event loop; it does not touch TLS verification, credential handling, or input parsing. The failure mode being fixed (callbacks after pause()) is a correctness/back-pressure issue, not a security boundary. The fixture uses rejectUnauthorized: false against its own local self-signed server, which is standard for the test suite.

Level of scrutiny

High. loop.c is the shared uSockets event-loop core across epoll/kqueue/libuv, and the low-priority queue state machine has several interacting invariants (low_prio_state ∈ {0,1,2}, group vs. low_prio_head linkage sharing prev/next, low_prio_count, poll-event bits, is_paused). I traced the new branch's exit state against every later consumer: us_socket_resume (arms reads, dispatch re-gates via budget since low_prio_state==0), us_socket_close (low_prio_state != 1 so it takes the group-unlink path, which is correct since the socket was re-linked before the check), and the timeout sweep (socket is back in head_sockets). The pattern mirrors the us_socket_is_closed branch two lines above, which also continues after adjusting state. REVIEW.md's cross-platform parity check applies here; the drain code itself is platform-shared, but the fixture's exact clientHandshakesWhilePaused: 5 assertion depends on iteration-level timing that the author verified on one platform (20/20 runs) and is not gated behind isWindows.

Other factors

The test follows repo conventions closely: test.concurrent, port: 0, deadline-bounded polling on an observable counter (no sleep), summary asserted before exit code via a single .toEqual on the whole object, fixture placed next to sibling tls-*-fixture.ts files, and bun:internal-for-testing used rather than adding production surface. The PR description demonstrates a precise mechanistic understanding (budget, LIFO drain, low_prio_state transitions) and lists the suites run on debug+ASAN. No CODEOWNERS entry covers packages/bun-usockets/. The bug hunt exited on dry_streak with no findings. Given the criticality of the code path and the tight timing assertion in the test, deferring to a human is the safer call over auto-approval.

@robobun

robobun commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:41 AM PT - Sep 11th, 2026

✅ @robobun, your commit 82a3ce13dd847b7977f3974ff505defa59a70a40 passed in Build #114372! 🎉


🧪   To try this PR locally:

bunx bun-pr 42351

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

bun-42351 --bun

@Jarred-Sumner
Jarred-Sumner merged commit 5e75689 into main Sep 29, 2026
7 of 8 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/33810928/tls-pause-low-prio-drain branch September 29, 2026 22:09
robobun added a commit that referenced this pull request Sep 30, 2026
Conflict in us_internal_handle_low_priority_sockets (loop.c), with
#42351. The function keeps the early continue for a paused socket of
#42351. For every other socket it sets low_prio_state to 2 and then
makes the checked us_poll_change of this branch.
@robobun

robobun commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

The fixture this PR added (tls-pause-parked-handshake-fixture.ts) gives the server's flight 36 loop iterations to reach the client, which is flaky on darwin x64 (build 122311 failed every retry). #44395 runs the fixture over a unix socket on macOS, where a write delivers at once.

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.

2 participants