From 3a46a69fcecf75cdf65d55e007bea91c701dfa21 Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Sat, 13 Jun 2026 21:39:16 +0100 Subject: [PATCH] fix: keep WebSocket binaryType + close handling stable across compat dates Three related WebSocket fixes so PartyServer behaves identically on every Worker compatibility date, plus the tooling to prove it. All changes are no-ops on older dates and corrective on newer ones (backward compatible). 1. Pin binaryType to "arraybuffer" for non-hibernating connections. On compat dates >= 2026-03-17 the `websocket_standard_binary_type` flag flips the default server-side `binaryType` from "arraybuffer" to "blob", so binary frames arrived as `Blob` instead of `ArrayBuffer` on the in-memory accept path. Every PartyServer consumer (and frameworks built on it, e.g. Cloudflare Agents / @cloudflare/voice) has always received `ArrayBuffer`, so it is pinned back in `InMemoryConnectionManager.accept`. The Hibernation API is unaffected (it always delivers `ArrayBuffer`). 2. Accept non-hibernating connections in half-open mode. `accept({ allowHalfOpen: true })` (with a try/catch fallback to bare accept() for older runtimes) keeps PartyServer's manual close handshake in control on compat dates >= 2026-04-07, where `web_socket_auto_reply_to_close` would otherwise auto-tear-down a tunneled DO socket and surface a spurious retryable "Network connection lost." rejection. 3. Stop reporting transport-teardown errors via onError. A retryable "Network connection lost." / "WebSocket peer disconnected" error that fires on an already CLOSING/CLOSED connection is the socket going away during the close handshake, not an application error. New `transport-errors.ts` (`isBenignTeardownError`, structured `retryable`-first detection so it stays correct under `enhanced-error-serialization` >= 2026-04-21) suppresses it in both the in-memory `handleErrorFromClient` and hibernating `webSocketError` paths. Genuine mid-connection (OPEN) errors still reach onError. Verification: - Bumped wrangler ^4.86.0 -> ^4.100.0 so local `wrangler dev` runs compat dates through 2026-06-11 (older workerd capped at 2026-05-03). - New `BinaryTypeProbe` DO + `compat-binarytype.test.ts` + parametrized `vitest.compat.config.ts` lock the ArrayBuffer contract across dates; the `test:compat-matrix` script runs it at 2026-01-28 / 03-24 / 04-07 / 04-21 and is wired into PR CI. The gate is non-vacuous (reports "blob" without the pin, "arraybuffer" with it). - 6 new `transport-errors.test.ts` unit tests cover the suppression predicate. - Full suite: 86 unit tests pass; typecheck, lint, format clean. Changeset: patch -> 0.5.7. Co-authored-by: Cursor --- .changeset/in-memory-half-open-accept.md | 12 + .github/workflows/pullrequest.yml | 3 + .gitignore | 3 + fixtures/alarm-restart-e2e/package.json | 2 +- package-lock.json | 286 ++++++++++++++++-- package.json | 2 +- packages/partyserver/package.json | 2 + .../partyserver/scripts/compat-matrix.mjs | 37 +++ packages/partyserver/src/connection.ts | 38 ++- packages/partyserver/src/index.ts | 17 +- .../src/tests/compat-binarytype.test.ts | 52 ++++ .../src/tests/transport-errors.test.ts | 49 +++ .../src/tests/vitest.compat.config.ts | 25 ++ packages/partyserver/src/tests/worker.ts | 14 + packages/partyserver/src/tests/wrangler.jsonc | 7 +- packages/partyserver/src/transport-errors.ts | 34 +++ 16 files changed, 559 insertions(+), 24 deletions(-) create mode 100644 .changeset/in-memory-half-open-accept.md create mode 100644 packages/partyserver/scripts/compat-matrix.mjs create mode 100644 packages/partyserver/src/tests/compat-binarytype.test.ts create mode 100644 packages/partyserver/src/tests/transport-errors.test.ts create mode 100644 packages/partyserver/src/tests/vitest.compat.config.ts create mode 100644 packages/partyserver/src/transport-errors.ts diff --git a/.changeset/in-memory-half-open-accept.md b/.changeset/in-memory-half-open-accept.md new file mode 100644 index 00000000..da5555dc --- /dev/null +++ b/.changeset/in-memory-half-open-accept.md @@ -0,0 +1,12 @@ +--- +"partyserver": patch +--- + +Accept non-hibernating WebSocket connections in half-open mode (`accept({ allowHalfOpen: true })`). + +On compatibility dates `>= 2026-04-07` the `web_socket_auto_reply_to_close` flag makes the runtime send a reciprocal Close frame and tear the socket down automatically. For a non-hibernating PartyServer (`hibernate: false`), the Durable Object sits on the server end of a connection that the runtime tunnels back to the client, so that auto-teardown could fire through an already-severed tunnel — surfacing as a spurious retryable `Network connection lost.` rejection (for example when a Durable Object is reset while a connection is still open). Half-open mode keeps PartyServer's existing close handling in control; it already reciprocates the peer's Close frame on every compatibility date, so client behavior is unchanged. + +Also in this release, two related WebSocket fixes that keep behavior consistent across all compatibility dates: + +- **Pin `binaryType` to `"arraybuffer"` for non-hibernating connections.** On compatibility dates `>= 2026-03-17` the `websocket_standard_binary_type` flag flips the default server-side `binaryType` from `"arraybuffer"` to `"blob"`, so binary frames arrived as `Blob` instead of `ArrayBuffer` on the in-memory path. PartyServer (and frameworks built on it, e.g. Cloudflare Agents) have always received `ArrayBuffer`, so it is now pinned back in `accept()`. This is a no-op on older dates and corrective on newer ones; the Hibernation API is unaffected (it always delivers `ArrayBuffer`). +- **Stop reporting transport-teardown errors as `onError`.** A retryable `Network connection lost.` / `WebSocket peer disconnected` error that fires on an already closing/closed connection is the socket going away during the close handshake, not an application error. It is now suppressed when the connection is `CLOSING`/`CLOSED` (detected via the structured `retryable` flag, with a message fallback), so it no longer spams logs on abrupt client disconnects. Genuine mid-connection (`OPEN`) errors still reach `onError`. diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index bcbea861..a848860e 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -25,3 +25,6 @@ jobs: - run: npm ci - run: npm run build - run: npm run check + # Compat-matrix: lock the WebSocket binaryType contract across the + # compatibility-date flag transitions (websocket_standard_binary_type). + - run: npm run test:compat-matrix -w partyserver diff --git a/.gitignore b/.gitignore index 6670af25..0ee74b8b 100644 --- a/.gitignore +++ b/.gitignore @@ -135,3 +135,6 @@ dist .DS_Store .env .dev.vars + +# Temporary compat-verification harness (Layer A) +.compat-harness/ diff --git a/fixtures/alarm-restart-e2e/package.json b/fixtures/alarm-restart-e2e/package.json index 21724cbc..5caf7aba 100644 --- a/fixtures/alarm-restart-e2e/package.json +++ b/fixtures/alarm-restart-e2e/package.json @@ -16,6 +16,6 @@ "@cloudflare/vite-plugin": "^1.34.0", "@cloudflare/workers-types": "^4.20260426.1", "vite": "^8.0.10", - "wrangler": "^4.86.0" + "wrangler": "^4.100.0" } } diff --git a/package-lock.json b/package-lock.json index c8e2e155..ca3f051f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "typescript": "^6.0.3", "vite": "^8.0.10", "vitest": "4.1.5", - "wrangler": "^4.86.0" + "wrangler": "^4.100.0" } }, "fixtures/alarm-restart-e2e": { @@ -48,7 +48,7 @@ "@cloudflare/vite-plugin": "^1.34.0", "@cloudflare/workers-types": "^4.20260426.1", "vite": "^8.0.10", - "wrangler": "^4.86.0" + "wrangler": "^4.100.0" } }, "fixtures/chat": { @@ -765,13 +765,13 @@ } }, "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", - "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", "dev": true, "license": "MIT OR Apache-2.0", "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@cloudflare/unenv-preset": { @@ -808,6 +808,51 @@ "wrangler": "^4.86.0" } }, + "node_modules/@cloudflare/vite-plugin/node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/vite-plugin/node_modules/wrangler": { + "version": "4.86.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.86.0.tgz", + "integrity": "sha512-9aa/gbF/HiUeeUEwyQpW5LDPBEzyt7iaE6xHwm0vk2Ly8A6J+jh03pzchqVnCCWR832mNyA28MD8oAYt0Kfvlw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260426.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260426.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.3.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260426.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, "node_modules/@cloudflare/vitest-pool-workers": { "version": "0.15.1", "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.15.1.tgz", @@ -827,6 +872,51 @@ "vitest": "^4.1.0" } }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/kv-asset-handler": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.4.2.tgz", + "integrity": "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": { + "version": "4.86.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.86.0.tgz", + "integrity": "sha512-9aa/gbF/HiUeeUEwyQpW5LDPBEzyt7iaE6xHwm0vk2Ly8A6J+jh03pzchqVnCCWR832mNyA28MD8oAYt0Kfvlw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.27.3", + "miniflare": "4.20260426.0", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260426.1" + }, + "bin": { + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=20.3.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.20260426.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, "node_modules/@cloudflare/workerd-darwin-64": { "version": "1.20260426.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260426.1.tgz", @@ -913,9 +1003,9 @@ } }, "node_modules/@cloudflare/workers-types": { - "version": "4.20260426.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260426.1.tgz", - "integrity": "sha512-cBYeQaWwv/jFV8ualmwp6wIxmAf0rDe2DPPQwPbslKmPHqgv861YpAvm45r05K40QboZgxNQVIPgNkmtHqZeJQ==", + "version": "4.20260613.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260613.1.tgz", + "integrity": "sha512-1mrgjE6epolwBhroeGAp5ud5H6Vyi6tl1o/NP0T4rXJ8bmEjmhHnbCzAhHTDHV0PIeip43wcuzHKJarvaGTaUA==", "license": "MIT OR Apache-2.0" }, "node_modules/@cspotcode/source-map-support": { @@ -11190,33 +11280,34 @@ } }, "node_modules/wrangler": { - "version": "4.86.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.86.0.tgz", - "integrity": "sha512-9aa/gbF/HiUeeUEwyQpW5LDPBEzyt7iaE6xHwm0vk2Ly8A6J+jh03pzchqVnCCWR832mNyA28MD8oAYt0Kfvlw==", + "version": "4.100.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.100.0.tgz", + "integrity": "sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { - "@cloudflare/kv-asset-handler": "0.4.2", + "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", - "miniflare": "4.20260426.0", + "miniflare": "4.20260611.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260426.1" + "workerd": "1.20260611.1" }, "bin": { + "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" }, "engines": { - "node": ">=20.3.0" + "node": ">=22.0.0" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "2.3.3" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260426.1" + "@cloudflare/workers-types": "^4.20260611.1" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { @@ -11224,6 +11315,165 @@ } } }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260611.1.tgz", + "integrity": "sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260611.1.tgz", + "integrity": "sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260611.1.tgz", + "integrity": "sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260611.1.tgz", + "integrity": "sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260611.1.tgz", + "integrity": "sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/wrangler/node_modules/miniflare": { + "version": "4.20260611.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260611.0.tgz", + "integrity": "sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.34.5", + "undici": "7.24.8", + "workerd": "1.20260611.1", + "ws": "8.20.1", + "youch": "4.1.0-beta.10" + }, + "bin": { + "miniflare": "bootstrap.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/wrangler/node_modules/undici": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/wrangler/node_modules/workerd": { + "version": "1.20260611.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260611.1.tgz", + "integrity": "sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260611.1", + "@cloudflare/workerd-darwin-arm64": "1.20260611.1", + "@cloudflare/workerd-linux-64": "1.20260611.1", + "@cloudflare/workerd-linux-arm64": "1.20260611.1", + "@cloudflare/workerd-windows-64": "1.20260611.1" + } + }, + "node_modules/wrangler/node_modules/ws": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 7dee69ea..bde812f4 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "typescript": "^6.0.3", "vite": "^8.0.10", "vitest": "4.1.5", - "wrangler": "^4.86.0" + "wrangler": "^4.100.0" }, "overrides": { "@types/node": "25.6.0", diff --git a/packages/partyserver/package.json b/packages/partyserver/package.json index 48f8c035..d9bdcabc 100644 --- a/packages/partyserver/package.json +++ b/packages/partyserver/package.json @@ -13,6 +13,8 @@ "scripts": { "check:test": "vitest -r src/tests --watch false", "test": "vitest -r src/tests", + "test:compat": "vitest run --config src/tests/vitest.compat.config.ts", + "test:compat-matrix": "node scripts/compat-matrix.mjs", "build": "tsx scripts/build.ts" }, "files": [ diff --git a/packages/partyserver/scripts/compat-matrix.mjs b/packages/partyserver/scripts/compat-matrix.mjs new file mode 100644 index 00000000..65616596 --- /dev/null +++ b/packages/partyserver/scripts/compat-matrix.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +// Layer B compat-matrix runner. Runs the date-agnostic `compat-*.test.ts` suite +// under vitest-pool-workers at several compatibility dates, overriding the +// runtime date per run via the COMPAT_DATE env var. +// +// Dates are capped at the workerd version bundled by this repo's +// vitest-pool-workers (currently ~2026-04-26). The 2026-06-11 ceiling and the +// real-network behaviors are covered by the Layer A `.compat-harness/` (real +// wrangler dev) and by the agents/voice suites in the agents repo. + +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), ".."); +const CONFIG = "src/tests/vitest.compat.config.ts"; + +const DATES = process.argv.slice(2).length + ? process.argv.slice(2) + : ["2026-01-28", "2026-03-24", "2026-04-07", "2026-04-21"]; + +let failed = false; +for (const date of DATES) { + console.log(`\n=== compat-matrix: COMPAT_DATE=${date} ===`); + const res = spawnSync("npx", ["vitest", "run", "--config", CONFIG], { + cwd: PKG_DIR, + stdio: "inherit", + env: { ...process.env, COMPAT_DATE: date } + }); + if (res.status !== 0) { + failed = true; + console.error(`compat-matrix FAILED at ${date}`); + } +} + +console.log(failed ? "\ncompat-matrix: FAIL" : "\ncompat-matrix: PASS"); +process.exit(failed ? 1 : 0); diff --git a/packages/partyserver/src/connection.ts b/packages/partyserver/src/connection.ts index a1cf1142..2d9ddf2d 100644 --- a/packages/partyserver/src/connection.ts +++ b/packages/partyserver/src/connection.ts @@ -319,7 +319,43 @@ export class InMemoryConnectionManager implements ConnectionManager { } accept(connection: Connection, options: { tags: string[] }) { - connection.accept(); + // Accept in half-open mode so PartyServer stays in control of the close + // handshake. On compat dates >= 2026-04-07 the `web_socket_auto_reply_to_close` + // flag otherwise makes the runtime send a reciprocal Close frame and tear + // the socket down automatically — behind the back of our own close handling + // (`handleCloseFromClient` already reciprocates via `closeQuietly`). That + // auto-teardown is the WebSocket-proxying hazard called out in the flag + // docs: because a PartyServer Durable Object sits on the server end of a + // connection that the runtime tunnels back to the client, the auto-close + // can fire through an already-severed tunnel and surface as a spurious + // retryable "Network connection lost." rejection (e.g. when a Durable + // Object is reset while a connection is still open). Half-open mode + // restores the historical behavior our close handlers were written for; + // `closeQuietly` still sends the reciprocal frame on every compat date. + try { + connection.accept({ allowHalfOpen: true }); + } catch { + // Older runtime/shim builds may not accept the options argument. Falling + // back to a bare accept() matches the pre-2026-04-07 behavior (no runtime + // auto-reply), which PartyServer's manual close handling already covers. + connection.accept(); + } + + // Preserve PartyServer's historical binary delivery contract. On compat + // dates >= 2026-03-17 the `websocket_standard_binary_type` flag flips the + // default server-side `binaryType` from "arraybuffer" to "blob", so binary + // frames arrive as `Blob` instead of `ArrayBuffer` on this in-memory path. + // Every PartyServer consumer (and the frameworks built on it, e.g. + // Cloudflare Agents) has always received `ArrayBuffer`, so pin it back. + // This is a no-op on older dates (already the default) and is corrective on + // newer ones. Guarded because some runtime/shim builds may not expose a + // settable `binaryType`. The hibernation path is unaffected (the + // Hibernation API always delivers `ArrayBuffer`). + try { + connection.binaryType = "arraybuffer"; + } catch { + // older runtimes may not allow setting binaryType here + } const tags = prepareTags(connection.id, options.tags); diff --git a/packages/partyserver/src/index.ts b/packages/partyserver/src/index.ts index 9200baec..35cbcf1c 100644 --- a/packages/partyserver/src/index.ts +++ b/packages/partyserver/src/index.ts @@ -8,6 +8,8 @@ import { isPartyServerWebSocket } from "./connection"; +import { isBenignTeardownError } from "./transport-errors"; + import type { ConnectionManager } from "./connection"; import type { Connection, @@ -761,6 +763,14 @@ export class Server< return; } + // Suppress retryable transport-teardown errors on an already closing/closed + // socket — the connection going away during/after the close handshake, not + // an application error. Genuine mid-connection (OPEN) errors still reach + // onError below. + if (isBenignTeardownError(ws, error)) { + return; + } + try { const connection = createLazyConnection(ws); @@ -904,8 +914,11 @@ export class Server< const handleErrorFromClient = (e: ErrorEvent) => { connection.removeEventListener("message", handleMessageFromClient); connection.removeEventListener("error", handleErrorFromClient); - this.onError(connection, e.error)?.catch((e) => { - console.error("onError error:", e); + // A transport-teardown error on an already closing/closed connection is + // the socket going away during the close handshake, not an app error. + if (isBenignTeardownError(connection, e.error)) return; + this.onError(connection, e.error)?.catch((err) => { + console.error("onError error:", err); }); }; diff --git a/packages/partyserver/src/tests/compat-binarytype.test.ts b/packages/partyserver/src/tests/compat-binarytype.test.ts new file mode 100644 index 00000000..7bcce072 --- /dev/null +++ b/packages/partyserver/src/tests/compat-binarytype.test.ts @@ -0,0 +1,52 @@ +import { createExecutionContext } from "cloudflare:test"; +import { env } from "cloudflare:workers"; +import { describe, expect, it } from "vitest"; + +import worker from "./worker"; + +// Layer B: the fast, date-parametrized CI gate for the binaryType contract. +// +// The `BinaryTypeProbe` DO reports its server-side `connection.binaryType` in +// onConnect. On compatibility dates >= 2026-03-17 the +// `websocket_standard_binary_type` flag flips the default to "blob"; the pin in +// `InMemoryConnectionManager.accept` must keep it "arraybuffer". This file is +// included in both the normal suite (at the wrangler.jsonc date) AND the +// compat-matrix run (`vitest.compat.config.ts`, which overrides the date via +// the COMPAT_DATE env var), so the contract is locked across the matrix. + +const COMPAT_DATE = process.env.COMPAT_DATE ?? "wrangler-default (2026-01-28)"; + +describe(`binaryType contract @ ${COMPAT_DATE}`, () => { + it("delivers a non-hibernating connection's binaryType as 'arraybuffer'", async () => { + const ctx = createExecutionContext(); + const res = await worker.fetch( + new Request("http://example.com/parties/binary-type-probe/probe-room", { + headers: { Upgrade: "websocket" } + }), + env, + ctx + ); + + expect(res.webSocket).toBeTruthy(); + const ws = res.webSocket!; + ws.accept(); + + const reported = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("timed out waiting for binaryType report")), + 2000 + ); + ws.addEventListener( + "message", + (event) => { + clearTimeout(timer); + resolve(String(event.data)); + }, + { once: true } + ); + }); + + expect(reported).toBe("arraybuffer"); + ws.close(1000, "done"); + }); +}); diff --git a/packages/partyserver/src/tests/transport-errors.test.ts b/packages/partyserver/src/tests/transport-errors.test.ts new file mode 100644 index 00000000..7c079c8f --- /dev/null +++ b/packages/partyserver/src/tests/transport-errors.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { isBenignTeardownError } from "../transport-errors"; + +// readyState values: 0 CONNECTING, 1 OPEN, 2 CLOSING, 3 CLOSED +const OPEN = { readyState: 1 }; +const CLOSING = { readyState: 2 }; +const CLOSED = { readyState: 3 }; + +const retryable = { retryable: true, message: "Network connection lost." }; +const networkLost = { message: "Network connection lost." }; +const peerGone = { message: "WebSocket peer disconnected" }; +const appError = { message: "user threw in onMessage" }; + +describe("isBenignTeardownError", () => { + it("suppresses retryable teardown errors on a closing/closed socket", () => { + expect(isBenignTeardownError(CLOSING, retryable)).toBe(true); + expect(isBenignTeardownError(CLOSED, retryable)).toBe(true); + }); + + it("suppresses known teardown messages on a closing/closed socket (string fallback)", () => { + expect(isBenignTeardownError(CLOSED, networkLost)).toBe(true); + expect(isBenignTeardownError(CLOSED, peerGone)).toBe(true); + }); + + it("does NOT suppress when the socket is still OPEN (real mid-connection error)", () => { + expect(isBenignTeardownError(OPEN, retryable)).toBe(false); + expect(isBenignTeardownError(OPEN, networkLost)).toBe(false); + }); + + it("does NOT suppress a genuine application error even on a closed socket", () => { + expect(isBenignTeardownError(CLOSED, appError)).toBe(false); + }); + + it("prefers the structured retryable flag over message text", () => { + // No recognizable message, but retryable === true -> still benign. + expect( + isBenignTeardownError(CLOSED, { retryable: true, message: "weird" }) + ).toBe(true); + }); + + it("handles non-object errors safely", () => { + expect(isBenignTeardownError(CLOSED, undefined)).toBe(false); + expect(isBenignTeardownError(CLOSED, "Network connection lost.")).toBe( + false + ); + expect(isBenignTeardownError({}, retryable)).toBe(false); + }); +}); diff --git a/packages/partyserver/src/tests/vitest.compat.config.ts b/packages/partyserver/src/tests/vitest.compat.config.ts new file mode 100644 index 00000000..3788fa8a --- /dev/null +++ b/packages/partyserver/src/tests/vitest.compat.config.ts @@ -0,0 +1,25 @@ +import path from "node:path"; +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +// Compat-matrix config (Layer B). Runs ONLY the date-agnostic `compat-*.test.ts` +// files, overriding the runtime compatibility date via the COMPAT_DATE env var +// (compatibilityFlags are left as-is from wrangler.jsonc, since the suite needs +// the nodejs test flags). The main `vitest.config.ts` keeps the full, +// date-locked suite pinned at the wrangler.jsonc date (2026-01-28). +const compatDate = process.env.COMPAT_DATE; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: path.join(import.meta.dirname, "wrangler.jsonc") + }, + ...(compatDate ? { miniflare: { compatibilityDate: compatDate } } : {}) + }) + ], + test: { + setupFiles: [path.join(import.meta.dirname, "setup.ts")], + include: [path.join(import.meta.dirname, "compat-*.test.ts")] + } +}); diff --git a/packages/partyserver/src/tests/worker.ts b/packages/partyserver/src/tests/worker.ts index 7c9da943..f5643c70 100644 --- a/packages/partyserver/src/tests/worker.ts +++ b/packages/partyserver/src/tests/worker.ts @@ -44,8 +44,22 @@ export type Env = { ThrowingCloseInMemory: DurableObjectNamespace; UserClosesInOnCloseHibernating: DurableObjectNamespace; UserClosesInOnCloseInMemory: DurableObjectNamespace; + BinaryTypeProbe: DurableObjectNamespace; }; +/** + * Reports the server-side `connection.binaryType` back to the client in + * `onConnect`. Used by the compat-matrix suite to lock the ArrayBuffer binary + * delivery contract across compatibility dates: on dates >= 2026-03-17 the + * `websocket_standard_binary_type` flag would otherwise default this to "blob". + * Non-hibernating so it exercises the in-memory accept path where the pin lives. + */ +export class BinaryTypeProbe extends Server { + onConnect(connection: Connection): void { + connection.send(connection.binaryType); + } +} + export class Stateful extends Server { static options = { hibernate: true diff --git a/packages/partyserver/src/tests/wrangler.jsonc b/packages/partyserver/src/tests/wrangler.jsonc index e2d78937..08b4d095 100644 --- a/packages/partyserver/src/tests/wrangler.jsonc +++ b/packages/partyserver/src/tests/wrangler.jsonc @@ -134,6 +134,10 @@ { "name": "UserClosesInOnCloseInMemory", "class_name": "UserClosesInOnCloseInMemory" + }, + { + "name": "BinaryTypeProbe", + "class_name": "BinaryTypeProbe" } ] }, @@ -172,7 +176,8 @@ "ThrowingCloseHibernating", "ThrowingCloseInMemory", "UserClosesInOnCloseHibernating", - "UserClosesInOnCloseInMemory" + "UserClosesInOnCloseInMemory", + "BinaryTypeProbe" ] } ] diff --git a/packages/partyserver/src/transport-errors.ts b/packages/partyserver/src/transport-errors.ts new file mode 100644 index 00000000..67e8658a --- /dev/null +++ b/packages/partyserver/src/transport-errors.ts @@ -0,0 +1,34 @@ +// Internal helpers for classifying WebSocket transport errors. +// Not re-exported from the package entry point; imported by `index.ts` and +// exercised directly by `tests/transport-errors.test.ts`. + +/** Standard `WebSocket.readyState` values. */ +const CLOSING = 2; +const CLOSED = 3; + +/** + * A retryable transport-teardown error ("Network connection lost" / + * "WebSocket peer disconnected") that fires on a connection which is already + * CLOSING/CLOSED is just the socket going away during or right after the close + * handshake - not an application error. Surfacing it via `onError` spams logs + * on every abrupt client disconnect, and even on clean closes when the peer + * tears down its transport before our reciprocal Close frame lands. Suppress + * it in that specific case only; genuine mid-connection (OPEN) errors still + * reach `onError`. + * + * Detection prefers the structured `retryable` flag over message text so it + * stays correct across `enhanced-error-serialization` (compat date + * >= 2026-04-21), with a substring fallback for older error shapes. + */ +export function isBenignTeardownError( + ws: { readyState?: number }, + error: unknown +): boolean { + const state = ws.readyState; + if (state !== CLOSING && state !== CLOSED) return false; + if (typeof error !== "object" || error === null) return false; + const typed = error as { retryable?: boolean; message?: unknown }; + if (typed.retryable === true) return true; + const message = typeof typed.message === "string" ? typed.message : ""; + return /Network connection lost|WebSocket peer disconnected/i.test(message); +}