Skip close handshake reciprocation for reserved codes#394
Merged
threepointone merged 2 commits intomainfrom Apr 28, 2026
Merged
Skip close handshake reciprocation for reserved codes#394threepointone merged 2 commits intomainfrom
threepointone merged 2 commits intomainfrom
Conversation
Followup to #393. The close handshake fix landed in 0.5.4 normalized the reserved synthetic codes (1005 NoStatusReceived, 1006 AbnormalClosure, 1015 TLSHandshake) to 1000 and reciprocated anyway, on the theory that calling `ws.close(...)` was always safe. In practice it isn't: those codes are precisely the runtime's signal that there was no real Close frame from the peer — the underlying transport is already gone. The reciprocating `ws.close(...)` succeeds synchronously but schedules an outbound write on a dead transport, which the runtime later rejects asynchronously with `Network connection lost` / `WebSocket peer disconnected`. That rejection can't be observed from `closeQuietly`'s synchronous try/catch (the call returns void, no Promise to attach a `.catch` to), so it surfaces as an unhandled promise rejection in tests and production logs. Surfaced by Cloudflare Agents sub-agent routing, where the WebSocket pair is tunneled across Durable Object RPC boundaries: the runtime delivered `webSocketClose(ws, 1005, "", true)` reliably, our reciprocation tried to write a Close frame back through an already-severed RPC link, and vitest reported `Errors 11` on an otherwise-passing 21-test suite. The fix is to recognize the reserved-code shape and skip the reciprocation entirely. There is no peer to acknowledge to. Both the hibernating `webSocketClose` path and the non-hibernating `#attachSocketEventHandlers` close listener pick this up via the shared `closeQuietly` helper. Adds one regression test under `Close handshake (#389) > hibernating` that exercises a client `ws.close()` with no code (the cleanest way to drive a code-1005 arrival on the server in the in-process test runner) and asserts that `onClose` still runs with the reserved code while the framework no longer attempts a reciprocation. User-visible behavior change: a client that calls `ws.close()` with no code on a server running a compatibility date `< 2026-04-07` (where the runtime's `web_socket_auto_reply_to_close` flag isn't yet active) will now observe a non-clean close instead of the previously-fabricated 1000 reciprocation. Clients that pass an explicit close code, and any client on compatibility dates `>= 2026-04-07` (auto-reply does the work), are unaffected. Verification: - `npm run check:test -w partyserver`: 73/73 pass. - `npm run check:type`, `check:lint`, `check:format`: clean. - Repro suite in cloudflare/agents (examples/assistant): 21/21 pass with 0 errors. Was 21/21 + 11 unhandled rejection errors before this fix. Made-with: Cursor
🦋 Changeset detectedLatest commit: 090316c The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
hono-party
partyfn
partyserver
partysocket
partysub
partysync
partytracks
partywhen
y-partyserver
commit: |
Update partyserver dependency from ^0.5.3 to ^0.5.4 across the monorepo and refresh package-lock.json. Affected package.json files: packages/hono-party, packages/partysub, packages/partysync, packages/partywhen, packages/y-partyserver. No other functional changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Followup to #393. The close-handshake fix in
0.5.4normalized the reserved synthetic close codes (1005NoStatusReceived,1006AbnormalClosure,1015TLSHandshake) to1000and reciprocated anyway, on the theory that callingws.close(...)was always safe. In practice it isn't: those codes are precisely the runtime's signal that there was no real Close frame from the peer — the underlying transport is already gone. The reciprocatingws.close(...)succeeds synchronously but schedules an outbound write on a dead transport, which the runtime later rejects asynchronously withNetwork connection lost/WebSocket peer disconnected. That rejection can't be observed fromcloseQuietly's synchronoustry/catch(the call returns void, no Promise to attach a.catchto), so it surfaces as an unhandled promise rejection in tests and production logs.The fix is to recognize the reserved-code shape and skip the reciprocation entirely. There is no peer to acknowledge to. Both the hibernating
webSocketClosepath and the non-hibernating#attachSocketEventHandlersclose listener pick this up via the sharedcloseQuietlyhelper.How it surfaced
Cloudflare Agents' sub-agent routing puts the WebSocket pair on opposite sides of a Durable Object RPC boundary (parent DO → facet DO). When a client closes the upgrade response immediately (a common pattern in tests), the runtime delivers
webSocketClose(ws, 1005, "", true)to the facet, our reciprocation tries to write a Close frame back through an already-severed RPC link, and the agents-repoexamples/assistantvitest suite reportsErrors 11on an otherwise-passing 21-test run with output like:Diagnosed via instrumented
closeQuietlyto confirmcode=1005, wasClean=true, readyState=2was the consistent shape at fault, and that downgrading the agents-repo topartyserver@0.5.3made the rejection vanish — pinning #393 as the proximate cause.Changes
packages/partyserver/src/index.ts— replacenormalizeCloseCode(code)withisReservedCloseCode(code)and havecloseQuietlyearly-return for reserved codes instead of normalizing-and-sending. Docstring updated to explain why (the synchronoustry/catchcan't see the asynchronous runtime rejection).packages/partyserver/src/tests/index.test.ts— one new regression test underClose handshake (#389) > hibernatingthat firesws.close()with no code (cleanest way to drive a code-1005 arrival on the server in the in-process test runner) and asserts (a)onClosestill runs with the reserved code on the server side, and (b) the test file completes without an unhandled rejection..changeset/quiet-foxes-skip-handshake.md— patch changeset.Why this is safe
1005/1006/1015are only ever synthesized locally to communicate "no close frame arrived." There is no peer state to flip from CLOSING → CLOSED on the other side.1000/1001/4xxx/ etc.) continue to reciprocate via the samecloseQuietlyhelper. All ten existingClose handshake (#389)tests still pass unchanged.>= 2026-04-07, the runtime'sweb_socket_auto_reply_to_closeflag handles the close handshake before our handler ever runs, so this code path is a no-op anyway.User-visible behavior change
A client that calls
ws.close()with no code on a server running a compatibility date< 2026-04-07(where auto-reply isn't yet active) will now observe a non-clean close instead of the previously-fabricated1000reciprocation. Clients that pass an explicit close code, and any client on compatibility dates>= 2026-04-07, are unaffected.Test plan
npm run check:test -w partyserver— 73/73 pass (62 existing + 10 from Complete the WebSocket close handshake in webSocketClose #393 + 1 new regression test)npm run check:type— cleannpm run check:lint— 0 warnings, 0 errorsnpm run check:format— cleanexamples/assistantrepro suite in cloudflare/agents — 21/21 pass with 0 errors (was 21/21 + 11 unhandled rejection errors before this fix)Made-with: Cursor
Made with Cursor