Skip to content

Commit 5335251

Browse files
committed
Complete the WebSocket close handshake in webSocketClose
When a client initiated a clean close, PartyServer's `webSocketClose` forwarded to user `onClose` but never reciprocated the peer's Close frame, leaving the client in CLOSING until it timed out and reported 1006 (abnormal closure). The Hibernation API contract requires the application to reciprocate on every compat date; the standard `accept()` API requires it on compat dates before 2026-04-07 (where the runtime's `web_socket_auto_reply_to_close` flag isn't yet active). Both the hibernating and non-hibernating paths now reciprocate via a shared `closeQuietly` helper in a `finally` block, after `onClose` has run (and even when `onClose` throws synchronously or asynchronously). Reserved close codes (1005, 1006, 1015) are normalized to 1000 so the reciprocation can never throw `InvalidAccessError`. Calling `close()` on an already-closed socket is a silent no-op, so user code that already calls `connection.close()` from `onClose` is unaffected. Adds 10 regression tests (5 hibernating + 4 non-hibernating + 1 cross-cutting) covering the headline #389 repro, peer code/reason delivery to onClose, throwing-onClose recovery, idempotent reciprocation when user code closes from onClose, and reserved-code normalization. The tests fail loudly without the fix with a clear "server-side WebSocket never reciprocated the peer's Close frame" timeout message. Fixes #389 Made-with: Cursor
1 parent 6273c96 commit 5335251

5 files changed

Lines changed: 593 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"partyserver": patch
3+
---
4+
5+
Complete the WebSocket close handshake when a client initiates the close. Previously, both the hibernating `webSocketClose` handler and the non-hibernating close-event listener forwarded to user `onClose` but never sent a reciprocal Close frame, leaving clients stuck in `CLOSING` until they timed out and reported `1006` (abnormal closure). The framework now reciprocates the peer's Close frame in a `finally` block on both paths — required by the Hibernation API on every compat date, and required by the standard `accept()` API on compat dates before `2026-04-07` (where the runtime's `web_socket_auto_reply_to_close` flag isn't yet active). Calling `close()` on an already-closed socket is a silent no-op, so user code that already calls `connection.close(...)` from `onClose` is unaffected. Reserved close codes (`1005`, `1006`, `1015`) are normalized to `1000` before reciprocation so they don't throw `InvalidAccessError`. See cloudflare/partykit#389.

packages/partyserver/src/index.ts

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,46 @@ export type WSMessage = ArrayBuffer | ArrayBufferView | string;
2222

2323
const NAME_STORAGE_KEY = "__ps_name";
2424

25+
/**
26+
* Reserved WebSocket close codes that cannot be sent in a Close frame.
27+
* - 1005 (NoStatusReceived) — stand-in for "no code in close frame".
28+
* - 1006 (AbnormalClosure) — synthesized for connections that drop
29+
* without a Close frame.
30+
* - 1015 (TLSHandshake) — TLS failure indicator.
31+
*
32+
* If we observe one of these in `webSocketClose`, normalize it to 1000
33+
* before reciprocating, otherwise `ws.close(code, reason)` will throw.
34+
*/
35+
function normalizeCloseCode(code: number): number {
36+
if (code === 1005 || code === 1006 || code === 1015) return 1000;
37+
return code;
38+
}
39+
40+
/**
41+
* Reciprocate a peer-initiated Close frame to complete the handshake.
42+
*
43+
* Best-effort: swallows errors from invalid codes, oversize reasons, or
44+
* sockets that have already been closed by user code. Used by both the
45+
* hibernating and non-hibernating close handlers to ensure the close
46+
* handshake always completes.
47+
*/
48+
function closeQuietly(ws: WebSocket, code: number, reason: string): void {
49+
try {
50+
ws.close(normalizeCloseCode(code), reason);
51+
} catch {
52+
// Reasons we end up here:
53+
// - the socket was already closed (user called `connection.close()`
54+
// in `onClose`, or the runtime auto-replied on compat dates
55+
// >= 2026-04-07 for the standard `accept()` API)
56+
// - `reason` exceeds the 123-byte UTF-8 limit (compat date
57+
// >= 2026-03-03)
58+
// - some other invariant violation we don't want to crash the
59+
// handler over
60+
// None of these are recoverable here; the handshake is either already
61+
// done or the runtime is out of our control.
62+
}
63+
}
64+
2565
// Let's cache the server namespace map
2666
// so we don't call it on every request
2767
const serverMapCache = new WeakMap<
@@ -515,12 +555,24 @@ export class Server<
515555
await this.#ensureInitialized();
516556
connection.server = this.name;
517557

518-
return this.onClose(connection, code, reason, wasClean);
558+
await this.onClose(connection, code, reason, wasClean);
519559
} catch (e) {
520560
console.error(
521561
`Error in ${this.#ParentClass.name}:${this.ctx.id.name ?? this.#_name ?? "<unnamed>"} webSocketClose:`,
522562
e
523563
);
564+
} finally {
565+
// Reciprocate the peer's Close frame to complete the handshake.
566+
// The Hibernation API requires applications to do this — without it,
567+
// clients stay in CLOSING and end up reporting 1006 abnormal closure.
568+
// The standard `accept()` API gets this for free on compat dates
569+
// >= 2026-04-07 via the `web_socket_auto_reply_to_close` flag, but the
570+
// Hibernation API contract is unchanged: see
571+
// https://developers.cloudflare.com/durable-objects/api/base/#websocketclose
572+
// Calling close() on an already-closed socket is a silent no-op, so
573+
// this is safe regardless of compat date or whether user code in
574+
// `onClose` already called `connection.close()`.
575+
closeQuietly(ws, code, reason);
524576
}
525577
}
526578

@@ -629,14 +681,44 @@ export class Server<
629681
});
630682
};
631683

684+
const reciprocateClose = (event: CloseEvent) => {
685+
// Reciprocate the peer's Close frame. On compat dates
686+
// >= 2026-04-07 the runtime's `web_socket_auto_reply_to_close`
687+
// flag will already have done this before the close event
688+
// fired, in which case `closeQuietly` is a silent no-op. On
689+
// older compat dates this is the only way the client gets a
690+
// clean close back.
691+
closeQuietly(connection, event.code, event.reason);
692+
};
693+
632694
const handleCloseFromClient = (event: CloseEvent) => {
633695
connection.removeEventListener("message", handleMessageFromClient);
634696
connection.removeEventListener("close", handleCloseFromClient);
635-
this.onClose(connection, event.code, event.reason, event.wasClean)?.catch(
636-
(e) => {
637-
console.error("onClose error:", e);
638-
}
639-
);
697+
let result: void | Promise<void>;
698+
try {
699+
result = this.onClose(
700+
connection,
701+
event.code,
702+
event.reason,
703+
event.wasClean
704+
);
705+
} catch (e) {
706+
// Synchronous throw from `onClose`. Log it and still
707+
// reciprocate the close so the client doesn't observe a 1006
708+
// abnormal closure on top of the user error.
709+
console.error("onClose error:", e);
710+
reciprocateClose(event);
711+
return;
712+
}
713+
if (result && typeof (result as Promise<void>).then === "function") {
714+
(result as Promise<void>)
715+
.catch((e) => {
716+
console.error("onClose error:", e);
717+
})
718+
.finally(() => reciprocateClose(event));
719+
} else {
720+
reciprocateClose(event);
721+
}
640722
};
641723

642724
const handleErrorFromClient = (e: ErrorEvent) => {

0 commit comments

Comments
 (0)