serve: keep the JS wrapper alive until no dispatch can fire - #32214
Conversation
Per-route handlers are stored as WriteBarriers reachable only via the Server JS wrapper. stop() previously downgraded js_value immediately, so a late keep-alive request after stop+drop+GC would hit js_value_assert_alive() with a Finalized ref and panic. Downgrade inside the deinit_if_we_can idle predicate instead so the wrapper stays rooted until pending_requests/listener/active websockets are all clear. The websocket close path does not yet call deinit_if_we_can; the next commit threads an AnyServer backref through Handler so the last close can trigger it.
The previous commit moved the JsRef downgrade into deinit_if_we_can's idle predicate, but nothing calls that when the last websocket closes after a graceful stop. Thread an AnyServer backref through Handler so on_close can trigger it; move the live-socket count onto NewServer (where reload's context swap can no longer reset it). on_close also copies the close handler to a stack local before sig.signal() so a GC between the test and the call cannot collect it.
…per finalize Idle keep-alive sockets are not counted in pending_requests, so the wrapper can downgrade and be collected while one such socket can still deliver another request. js_value_assert_alive() panics on Finalized; the dispatch entry points now check first and close the connection with 503 instead.
JsRef::Weak holds a raw JSValue, not a JSC::Weak: try_get() can return the address of a dead-but-unswept cell, so the Finalized→503 check alone leaves a window where dispatch reads an unrooted handler shadow. Closing idle connections at stop() removes the late-request source; in-flight requests are not idle and drain normally. The on_open error-path websocket-close accounting already runs after run_error_callback as of 65c76a2, so the live-socket count stays nonzero across that read; no further change needed there.
JsRef::Weak holds a raw JSValue: try_get() on Weak returns the address even when the cell is dead-but-unswept. Gating on Strong means trampolines refuse the moment the server goes idle (downgrade) rather than only after the wrapper destructor has run.
…et second request
|
Caution Review failedPull request was closed or merged during review WalkthroughThis PR refactors WebSocket connection lifecycle management from per-handler counters to per-server accounting, adds dispatch safety guards to prevent routing to unrooted JS wrappers, defers garbage-collection-safe JS value downgrades, and includes two regression tests for GC and graceful stop scenarios. ChangesWebSocket Lifecycle & Dispatch Safety
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Updated 1:48 PM PT - Jun 12th, 2026
❌ @autofix-ci[bot], your commit 0564a7b has 2 failures in
🧪 To try this PR locally: bunx bun-pr 32214That installs a local version of the PR into your bun-32214 --bun |
|
Folded into #32215. |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
Per-route handlers are stored as
WriteBarriers reachable only via the Server wrapper'sm_routeListslot.stop()previously downgradedjs_valueimmediately, so a late keep-alive request after stop+drop+GC could panic atjs_value_assert_alive()or dispatch with an unreachable route list.js_value.downgrade()moves fromstop()intodeinit_if_we_can()'s idle predicate (matching theJSNodeHTTPServerSocketclear-after-last-callback pattern)NewServer(so reload's context swap can't reset it);Handlergains anAnyServerbackref so on_close callsdeinit_if_we_can()when the last socket drainsstop_listening(false)also closes idle keep-alive connections so a late request can't arrive in the dead-unswept window whereJsRef::Weakholds a stale addressjs_valueis no longer strongon_closecopieshandler.on_closeto a stack local beforesig.signal()so a GC there can't collect the value before the callTests: late keep-alive request to a route after stop+drop+GC doesn't crash; server wrapper survives GC while a websocket is connected after stop, then collects after close (fails on the released binary).