Skip to content

usockets: do not run a socket's timeout while it waits in the TLS handshake queue - #46115

Open
robobun wants to merge 10 commits into
mainfrom
robobun/b923d377/proxy-stress-serial-cases
Open

robobun wants to merge 10 commits into
mainfrom
robobun/b923d377/proxy-stress-serial-cases

Conversation

@robobun

@robobun robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Merge before #45614, #45757 and #45758 (see the notes).

Problem

  • A busy TLS Bun.serve resets clients that wait for their handshake. Under ab -r -n 40000 -c 1000, Bun 1.4.3 fails 1280 to 1605 requests. Bun 1.4.2 fails 0 to 19.
  • Since Bun.serve(tls): reap every zero-byte pre-handshake connection in a burst #36403 the timeout sweep fires on sockets parked in the TLS handshake queue (packages/bun-usockets/src/loop.c:315). The 10 s request timeout closes a socket whose ClientHello nobody read.

Fix

  • For at most 120 s, the sweep moves a parked socket's timeout stamp on and does not fire it. Correct because the loop holds back that socket's reads: its peer is not idle.
  • A deferred accept over the handshake budget is parked only if it has input. A silent peer stays linked and closes at 10 s (Bun.serve(tls): reap every zero-byte pre-handshake connection in a burst #36403).
  • Verified: test/js/bun/net/tls-handshake-queue-timeout.test.ts (3 tests, each fails without the fix). Self-reviewed: 16 concerns raised, 13 addressed (notes).

Background

  • uSockets reads 5 handshaking TLS sockets per loop iteration and parks the others: reads off, unlinked.
  • A timeout is a stamp on a 240-slot wheel. Every 4 s a sweep fires linked sockets whose stamp equals the tick.
  • Considered a peek in HttpContext::onTimeout: a syscall per due socket, HTTP server only.

Downsides

  • A parked socket can live 120 s longer: a steady flood of clients that give up leaves about 1200 fds open (1.4.3: 120).
  • socket.timeout(n) on a parked Bun.listen TLS socket comes late by the parked time.
  • Cost: one recv(MSG_PEEK) per TLS accept over the budget, +4 instructions per deferred accept, +96 bytes.
Notes

The rule. A socket in the TLS handshake queue has input that this loop did not read. Nothing acts on the socket over that input. A FIN does not end it (#44618, the EOF arm of us_internal_dispatch_ready_poll). With this PR no timeout of its owner runs either, for at most 120 s. Only a socket with input is parked. The comment above MAX_LOW_PRIO_SOCKETS_PER_LOOP_ITERATION states the rule once.

Merge order. #45614 keeps the timeout dispatch in the walk over parked sockets. It also stores the stamp in a different way, and it tests that the handler of a parked socket runs. #45757 and #45758 edit the same walk and the comment of the #36403 fixture. This PR changes the rule that they build on, so each needs a rebase after it. sweepSocketTimers is commit 9dda2dc of #45614, cherry-picked.

How it was found. proxy-stress-concurrent.test.ts fails on a debug build on a slow host: 32× parallel http-proxy → https-origin keepalive=false rejects with ECONNRESET: The socket connection was closed unexpectedly. On an idle 12-vCPU VM 2 of 4 runs of the file fail. In a failing run the sweep closes 54 parked sockets, each with 1500 unread bytes (the ClientHello). With this PR the test is unchanged and passes. No user reported the resets, and CI did not show them (146 builds of main, 119566 to 128670). An earlier version of this PR made those cases test.serial. That change is gone.

Reproduce. Linux x64. Official builds: the release binaries of 1.4.2 and 1.4.3, and Node 26.3.0. Built here: a release and a debug build of this PR, and a debug build of main 91e7e3b140.

1.4.2 1.4.3 Node this PR (release) main (debug) this PR (debug)
ab -r -n 40000 -c 1000, handler returns "ok": failed requests 0, 19, 0 1280, 1605, 1528 0, 0, 0
80 TLS clients at once, handler spins 250 ms: clients lost 0, 3, 1, 1 21, 31, 24, 22 0, 0, 0, 0 0, 0, 0 27 to 34 0 in 8 runs
the same against node:https 3 32 0 29 to 45 0 in 6 runs
140 clients 87, 90 0 in 3 runs
140 clients through a TCP relay on the server's loop 100, 104 0 in 3 runs
the burst test of this PR pass answered 10, ECONNRESET 30 pass pass

The clients of the second to fifth row are Node tls.connect sockets in a second process. A lost client got ECONNRESET or a close with no answer, after about 13 s.

Not covered.

  • node:net and node:tls keep their timeouts in JS timers (socket.timeout(0) in src/js/node/net.ts). A socket timeout or the handshakeTimeout of a tls.Server still runs while the socket is parked.
  • A client with a short timeout of its own still gives up. This PR removes the reset, not the wait: the queue still reads 5 sockets per iteration.
  • TLS clients on the server's own loop. There the client socket can be the parked one. The server socket that waits for it is linked, so its timeout runs.
  • A hangup or an error on a parked socket closes it unread, as on main (usockets: read a parked socket before acting on its hangup #44229).
  • The sweep does not tick while every socket of the loop is parked (usockets: keep the loop turning while TLS handshakes are parked #45757).

Per backend. The accept-path change applies to listeners with a deferred accept: Bun.serve and node:http(s) over TCP on Linux and FreeBSD. macOS, Windows, unix sockets, Bun.listen, node:tls and client sockets get the fix from the sweep alone. I ran everything on Linux x64 only.

Cost. clang 23 with the release flags and no LTO, loop.c of main against this PR. Syscalls are counted with an LD_PRELOAD shim on debug builds.

  • Accept path: 24 to 28 instructions per plain-HTTP deferred accept (no load added), 29 to 33 per TLS accept (one load). A TLS accept over the budget adds one recv(MSG_PEEK).
  • Sweep: 4 instructions per tick with an empty queue (as before). 12 to 26 per parked socket, with 2 stores. The walk over linked sockets is instruction-identical.
  • loop.o .text: 6200 to 6296 bytes. sizeof(struct us_socket_t) stays 80: the tick counter is in padding.
  • 1,000 TLS connections one after another: 10 syscalls per connection on both builds, no peek.
  • 300 silent connections (the Bun.serve(tls): reap every zero-byte pre-handshake connection in a burst #36403 fixture): all closed on both builds. epoll_ctl(MOD) 465 to 469 becomes 0, recv 5 to 6 becomes 20 to 40, recv(MSG_PEEK) 0 becomes 260 to 280.
  • Flood: 12 new TLS clients per second for 240 s, each gives up after 8 s, handler spins 250 ms (release builds). Open fds of the server: 1.4.3 stays at 82 to 128. 1.4.2 grows for the whole run, to 1829. This PR stops at 1140 to 1230 after 150 s. Answered: 963, 964 and 968 of 2880.

Tests. All three are in test/js/bun/net/tls-handshake-queue-timeout.test.ts: 14 s on a release build, 18 s on a debug build.

  • The burst test runs on the real clock, 3 ticks. It fails on main and on the 1.4.3 binary with answered: 10, ECONNRESET: 30. It is not driven by the hook: the sweep must find the sockets where a slow loop leaves them, between iterations that give the clients time to answer.
  • The Bun.listen test and the deferred-accept test run the sweep by hand. main has no sweepSocketTimers, so there they fail at the import. With main's loop.c and the hook the summaries are oneTick: ["20,0"], parkedTooLong: [20, 0] and closedWhileParked: 12.
  • Each clause, removed in a scratch build: no 255 guard gives unarmed: [0, 15]. No % 240 adds "5,0" to oneTick. No 120 s limit gives parkedTooLong: [5, 0]. A stamp that is dropped and not moved gives oneTick: ["5,0"], which is also what 1.4.2 does. An accept path that skips every accept over the budget gives closedWhileParked: 12.
  • The Bun.serve(tls): reap every zero-byte pre-handshake connection in a burst #36403 test still passes. It is the check that a silent connection is closed.
  • proxy-stress-concurrent.test.ts on the debug build: 4 of 4 runs pass on the idle host (slowest case 10.1 to 12.7 s). Pinned to one core with two busy loops: main fails 5 of 5 runs with ECONNRESET. This PR passes 5 of 5, and the slowest case takes 24.2 to 28.7 s of its 30 s limit.
  • Other suites on the debug build: serve, bun-server, bun-serve-ssl, tls-keepalive, socket, socket-syscall-fault, tcp-server, tls-fatal-error-closes, tls-reject-before-client-cert, node-tls-server, node-tls-connect, tls-syscall-fault, renegotiation, node-tls-upgrade, node-tls-raw-end, serve-http2-lifecycle, proxy and the 7 proxy-stress files. The failures are the ones that main's binary has on this machine: tests that need the internet or a non-root user, and tests that are flaky on a debug build, most by their 5 s limit.

Self-review: not done.

Alternatives.

  • A peek in HttpContext::onTimeout: see Background.
  • A new full timeout when the queue releases a socket: a peer that stalls gets a new deadline each time its socket is parked.
  • A change to the test only: the server still resets the clients.

Questions for a maintainer.

  1. Is 120 s the right limit for a socket in the queue? It is the handshakeTimeout of Node. A lower limit closes waiting clients sooner and holds fewer fds in a flood. The limit is commit e43a0ac, separate from the rest.
  2. Is it acceptable that socket.timeout(n) of a Bun.listen TLS socket does not run while the socket is parked?

The eight "parallel requests, single origin" cases ran as one concurrent
group. All of their origins and proxies share the event loop of the test
process, so the cases added to each other's TLS handshake queue. On a
slow host with a debug build, the oldest handshakes waited longer than
the 10 s that Bun.serve gives a connection to send its first request.
The origin reset them and fetch() rejected with ECONNRESET.

test.serial() keeps the 32 parallel requests of each case and removes
the overlap between cases. The block takes the same time, because the
servers of all cases run on one thread.
@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 AM PT - Oct 21st, 2026

❌ @robobun, your commit 98769c0 has 1 failures in Build #129861 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46115

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

bun-46115 --bun

@coderabbitai

coderabbitai Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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: Advanced
  • Run ID: be1e9243-b4c2-413c-950a-afdf8d35ba5a
📥 Commits

Reviewing files that changed from the base of the PR and between 345cf23 and 98769c0.

📒 Files selected for processing: 11
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/libusockets.h
  • packages/bun-usockets/src/loop.c
  • src/js/internal-for-testing.ts
  • src/runtime/socket/socket_body.rs
  • src/uws_sys/Loop.rs
  • test/js/bun/http/serve-tls-prehandshake-reap-fixture.ts
  • test/js/bun/net/tls-handshake-queue-timeout.test.ts
  • test/js/bun/net/tls-parked-handshake-timeout-fixture.ts
  • test/js/bun/net/tls-serve-deferred-accept-fixture.ts
  • test/js/bun/net/tls-serve-parked-burst-fixture.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

Low-priority TLS sockets now advance timeout stamps while queued and receive timeout processing at the queue limit or on spill-close. A testing API sweeps socket timers. New regression fixtures cover parked sockets, TLS bursts, and deferred accepts.

Changes

TLS handshake queue timeouts

Layer / File(s) Summary
Low-priority queue timeout handling
packages/bun-usockets/src/internal/internal.h, packages/bun-usockets/src/loop.c, packages/bun-usockets/src/libusockets.h
Sockets track low-priority queue ticks. The loop advances armed timeout stamps while sockets remain queued and dispatches timeouts at the queue limit or on spill-close. Deferred-accept readable dispatch also checks the handshake budget and queued input.
Expose timer sweeps for tests
src/js/internal-for-testing.ts, src/runtime/socket/socket_body.rs, src/uws_sys/Loop.rs
The testing API accepts a sweep count and invokes the internal timer sweep for each iteration. The host function rejects nested sweeps.
Exercise queued TLS and deferred accepts
test/js/bun/net/tls-parked-handshake-timeout-fixture.ts, test/js/bun/net/tls-serve-parked-burst-fixture.ts, test/js/bun/net/tls-serve-deferred-accept-fixture.ts, test/js/bun/net/tls-handshake-queue-timeout.test.ts, test/js/bun/http/serve-tls-prehandshake-reap-fixture.ts
Regression tests check timeout behavior for parked sockets, responses to a TLS client burst, and closes for deferred accepts. The fixture comments describe the handshake budget and queue behavior.

Suggested reviewers: jarred-sumner

Priority: ⬆️ High

Merge Risk: ⚪ Minimal · up to 98769

No merge-blocking issue is established for the TLS handshake queue timeout change. It is ready for normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: preventing timeout processing for sockets waiting in the TLS handshake queue.
Description check Passed The description provides a detailed problem statement, implementation summary, tradeoffs, scope, limitations, and extensive verification results. It does not use the exact template headings, but it co…
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.
Full details: Description check

Explanation

The description provides a detailed problem statement, implementation summary, tradeoffs, scope, limitations, and extensive verification results. It does not use the exact template headings, but it covers both required areas: what the PR does and how it was verified.

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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/proxy-stress-concurrent.test.ts Outdated
@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on Linux x64 with the official release binaries:

  • ab -r -n 40000 -c 1000 against a TLS Bun.serve that answers "ok": Bun 1.4.3 fails 1280 to 1605 requests. Bun 1.4.2 fails 0 to 19.
  • 80 TLS clients at once against a handler that spins 250 ms: Bun 1.4.3 loses 21 to 31 clients (ECONNRESET). Bun 1.4.2 loses 0 to 3 and Node 26.3.0 loses 0.
  • bun bd test test/js/bun/http/proxy-stress-concurrent.test.ts on a debug build of main: 2 of 4 runs fail with ECONNRESET on an idle 12-vCPU VM.

With this PR (#46115): 0 failed requests, 0 lost clients, and the unchanged stress test passes 4 of 4 runs.

…andshake queue

The loop reads at most 5 handshaking TLS sockets per iteration and parks
the others with their reads off. Since #36403 the timeout sweep also
walks the parked sockets and runs their owner's timeout. So a server
that could not read a ClientHello within the 10 s that a connection has
to send its request closed the socket over the unread bytes, and a
client that had waited for the server got ECONNRESET.

The sweep now moves the stamp of a parked socket on with each tick. The
socket keeps the time it had left and the timeout comes due after the
queue releases it. The accept path no longer parks a socket on the
readable dispatch it synthesizes for a deferred accept: a peer that
sends nothing stays linked, where the sweep closes it as before.

The stress block that showed this runs its cases concurrently again,
with half the requests per case on a debug build.
@robobun
robobun marked this pull request as draft October 20, 2026 10:38
A client that the server neither answers nor closes now shows up in the
summary as "no answer" and does not hang the fixture.
…it has input

The accept path skipped the readable dispatch for every TLS accept over
the handshake budget. A socket that arrived with its ClientHello then
stayed linked and unread for one more loop iteration, with its timeout
running, and was parked one iteration later than on main.

The accept path now looks at the receive queue (one recv with MSG_PEEK
per accept over the budget). A socket with input is parked at once, as
on main, and its timeout stops there. A socket with no input stays
linked, where the sweep closes it.

Also states the rules of the queue in one comment.
Without a limit, the only things that take a socket out of the TLS
handshake queue are the drain and a reset. A FIN is not seen there. So
under a steady flood of new TLS connections the queue keeps the sockets
of clients that left, and their number grows for as long as the flood
lasts.

The sweep now counts the ticks a socket spends in the queue. After 30
ticks (120 s, the handshakeTimeout of node) the socket's timeouts run
again and its owner closes it.
With the fix in usockets the block needs no change to pass.
The Bun.listen fixture runs the sweep by hand: no sleep, and it also
covers the wrap of the wheel, a socket with no timeout armed and the
120 s limit. The Bun.serve fixture stays on the real clock, takes its
certificate and marker path from the environment and reports how many
requests the server had answered when the last tick came due.
… at once

Linux only: a connection without a deferred accept has no input yet when
the accept path runs.
The three tests spawn up to two Bun processes each. In
socket-syscall-fault.test.ts and bun-serve-ssl.test.ts they shared the
CPU of a debug build with tests that rely on the default 5 s timeout.
serve.test.ts, socket-syscall-fault.test.ts and bun-serve-ssl.test.ts
are as on main again.
@robobun robobun changed the title test(proxy-stress): run the 32x parallel cases one at a time usockets: do not run a socket's timeout while it waits in the TLS handshake queue Oct 21, 2026
@robobun
robobun marked this pull request as ready for review October 21, 2026 05:17

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

Beyond the inline findings, two things were checked and ruled out: dropping the s->ssl ? ... : us_dispatch_timeout branch in the parked-socket walk is safe because the only park site (loop.c:680) is gated on s->ssl, so a non-SSL socket never enters low_prio_head; and deferring long_timeout only when global_tick % 15 == 0 matches the group walk's long_timestamp = (global_tick / 15) % 240 at loop.c:261, since the group's tick is advanced before the parked walk runs.

Extended reasoning...

The change touches the uSockets timer sweep and deferred-accept path in packages/bun-usockets/src/loop.c, adds a byte field to us_socket_t, and exposes us_internal_timer_sweep to JS through bun:internal-for-testing; it touches no auth, crypto or input-parsing surface. Four verified findings are posted inline and the hunt ended at its bug cap, so approval is not appropriate; this note only records the SSL-only park gate and the long-timeout cadence as checked and consistent.

Findings marked 🟡 are optional suggestions and need no follow-up push.

loop->data.low_prio_head = s;

flags->low_prio_state = 1;
s->low_prio_ticks = 0;

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.

🔴 Under sustained TLS handshake overload, a client that trickles its handshake can hold a Bun.serve connection open past the 10 s idle timeout without bound, which the base branch closes. loop.c:716 zeroes low_prio_ticks on every park, so a socket the queue released, read once and parked again gets a fresh 120 s hold each time. MAX_LOW_PRIO_TICKS at loop.c:323 therefore bounds one park spell, not the socket. Fix: make the cap cumulative per socket, i.e. initialize low_prio_ticks at the socket init sites that set timeout = 255 (loop.c:565, socket.c:444, context.c:313) and stop resetting it at loop.c:716, so the owner's timeout runs after 30 held ticks in total.

Why this was flagged

A TLS server loop is over the 5-per-iteration handshake budget (loop.c:683) and iterates slowly enough that sweep ticks pass while sockets sit in the queue, the condition the PR itself targets. A client sends its handshake one record piece at a time. Its server socket is parked at loop.c:698-716 with low_prio_ticks = 0; each tick the sweep at loop.c:323-328 holds its stamps back instead of firing. When us_internal_handle_low_priority_sockets (loop.c:361-385) releases it, the next readable dispatch sets low_prio_state 0 at loop.c:682, reads one piece, and on the following piece the budget is spent again, so loop.c:716 parks it with low_prio_ticks reset to 0. The 30-tick cap at loop.c:323 restarts every cycle. On the base branch the parked walk fired the owner's timeout at its stamp, so HttpContext closed the connection at 10 s. The comment at loop.c:245-246 says the cap exists because past 120 s the peer has given up; it does not hold for a peer that keeps sending a byte per park spell.

Verification: loop.c:716 s->low_prio_ticks = 0; runs on every park, and the sweep at lines 323-328 pushes s->timeout forward one slot per tick, so each re-park restarts the 30-tick allowance. On the base branch an unfinished handshake was closed at ~10 s; after this change a client that trickles handshake pieces keeps its fd open for as long as the overload lasts.

Comment on lines +73 to +76
for (let tick = 0; tick < TICKS; tick++) {
Bun.sleepSync(TICK_MS);
await oneIteration();
}

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.

🟡 nit (optional): this fixture waits on the real clock for over 12 s, which CLAUDE.md's ~1 s per test budget and "never wait for time to pass in tests" rule reject. tls-serve-parked-burst-fixture.ts:74 calls Bun.sleepSync(TICK_MS) three times at 4100 ms so that the 4 s sweep fires between iterations. Fix: drive the ticks with sweepSocketTimers between the hand-run iterations, as tls-serve-deferred-accept-fixture.ts:82 already does for Bun.serve, so the test asserts the same answered: 40 without sleeping; if the real clock is load-bearing, say in one line what the hook cannot reproduce. [also at: test/js/bun/net/tls-handshake-queue-timeout.test.ts:73 - nit: explicit per-test timeouts, which test/CLAUDE.md forbids ("Do not set a timeout on tests"), sweep:^\s+(30|60)_000,$.]

Why this was flagged

test/js/bun/net/tls-handshake-queue-timeout.test.ts:46-57 runs tls-serve-parked-burst-fixture.ts, whose server loop at lines 73-76 blocks in Bun.sleepSync(TICK_MS) for TICKS = 3 iterations with TICK_MS = 4100, about 12.3 s of wall time plus the 40 TLS clients, and the PR text reports 14 s release and 18 s debug for the file. test/CLAUDE.md:21 says never wait for time to pass in tests and the root CLAUDE.md budget is about 1 s per test and 10 s per file; the repo review rules say a correct but slow test still gets changes-requested and that the workload should shrink rather than timeouts grow. The sweep is already exposed as sweepSocketTimers (src/js/internal-for-testing.ts:744) and the sibling fixture tls-serve-deferred-accept-fixture.ts:82 drives Bun.serve ticks with it, so no observable signal is missing. The 60_000 timeout at the test site exists only to cover this wait.

Verification: tls-serve-parked-burst-fixture.ts:73-76 is for (let tick = 0; tick < TICKS; tick++) { Bun.sleepSync(TICK_MS); await oneIteration(); } with TICK_MS = 4100 (line 28) and TICKS = 3 (line 29), i.e. ~12.3 s of blocking wall-clock sleep in the server process on every run. This violates test/CLAUDE.md:21 ("never wait for time to pass in tests"). Consequence is bounded: ~12 s of CI wall time per run, not a wrong result.

answer = Promise.withResolvers<number>();
socket.write("?");
return answer.promise;
};

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.

🟡 nit (optional): if the client process dies or the control connection closes, this fixture hangs until the test runner kills it instead of printing an error. ask() at tls-serve-deferred-accept-fixture.ts:59-63 awaits answer.promise with no deadline and nothing rejects it on child exit or socket close, although the header at line 26 says a stalled step ends in an error. Fix: reject the pending answer from the control socket's close/error handlers and from child.exited, or race it against STEP_DEADLINE_MS like the loop at lines 76-80.

Why this was flagged

server() at tls-serve-deferred-accept-fixture.ts:86 and :88 awaits ask(), which writes "?" on the control socket and returns answer.promise (lines 59-63). The only resolver is the control socket's data handler at line 55-57. The control listener at lines 51-66 defines no close or error handler, and child.exited (line 68) is not wired to the promise. If the spawned client exits early or the control socket drops, the promise never settles and the fixture waits until the 30_000 timeout at tls-handshake-queue-timeout.test.ts:73 kills it, with no JSON summary and no error naming the stalled step. The comment at line 26 claims every stalled step ends in an error; the file-wait loop at lines 76-80 has a deadline but ask() does not. The repo rules require every failure event to reject the awaited promise.

Verification: nit. The spawned client process exits or the control connection drops before it answers a "?". In test/js/bun/net/tls-serve-deferred-accept-fixture.ts, ask (lines 59-63) returns answer.promise; the only settle site is the data handler (lines 55-57). The Bun.listen handlers (lines 54-65) define only data and open, so the test only ends via the 30_000 ms per-test timeout.

s->long_timeout = 255;
us_dispatch_long_timeout(s);
if (loop_data->low_prio_iterator != s) continue;
if (!s->ssl_close_after_spill && s->low_prio_ticks < MAX_LOW_PRIO_TICKS) {

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.

🟡 (optional) Operators of a TLS Bun.serve under sustained handshake overload now keep sockets whose peers already hung up for up to ~130 s instead of ~13 s, so the parked set grows about 10x before anything reaps it. loop.c:323 defers a parked socket's timeout for 30 ticks without checking that the peer is still there, and a parked socket has READABLE off (loop.c:687), so a client FIN raises no event. Fix: stop deferring once the peer is gone, e.g. peek with us_socket_queued_input at each sweep tick and take the dispatch branch when the result is not LIBUS_QUEUED_INPUT_DATA, so the 120 s grace covers only peers still waiting. The PR notes ~1200 fds at 12 clients/s; at ab -c 1000 rates the same ratio reaches fd limits.

Why this was flagged

A TLS Bun.serve loop stays over the 5-per-iteration handshake budget (loop.c:683) for a long stretch, as in the PR's own flood run. Each over-budget readable dispatch parks the socket at loop.c:686-716 with READABLE masked off, and the LIFO queue starves the oldest entries. Clients that give up close their socket and send a FIN; with reads off, epoll reports only EPOLLHUP|EPOLLERR for that fd (epoll_kqueue.c:742-746), and a half-close is neither, so the server never learns the peer left. On the base branch the sweep fires the owner's 10 s stamp for a parked socket on exact match, so HttpContext closes it at the next tick and the parked set is bounded by about 13 s of arrivals. With the change, loop.c:323-328 pushes the stamp forward on every tick for MAX_LOW_PRIO_TICKS = 30 ticks, so the same socket, its SSL object and its kernel buffers survive until roughly tick 33 (~130 s); the parked set is bounded by ~130 s of arrivals, ten times more.

Verification: Parking turns reads off (loop.c:687), and with neither R nor W the epoll mask is only EPOLLHUP | EPOLLERR (epoll_kqueue.c:742-746); a peer FIN alone raises no event. After this change loop.c:323-328 unconditionally defers the stamp for MAX_LOW_PRIO_TICKS, with no check of whether the peer is still there. A dead-peer parked socket therefore holds its fd and SSL state for up to ~130 s instead of ~13 s.

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