Skip to content

fix(drift): a refused WS session states its close code instead of looking like silence - #373

Closed
jpr5 wants to merge 5 commits into
mainfrom
fix/ws-preserve-close-code
Closed

fix(drift): a refused WS session states its close code instead of looking like silence#373
jpr5 wants to merge 5 commits into
mainfrom
fix/ws-preserve-close-code

Conversation

@jpr5

@jpr5 jpr5 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

A provider rejecting a WebSocket session was indistinguishable from a provider going silent. src/__tests__/drift/ws-providers.ts — the drift probe's TLS client — discarded the server's CLOSE frame with a bare socket.end(), so the code and reason never reached anything.

That is why the Gemini Live failures of 2026-08-11 and 08-12 could not be explained: the one signal that would have named the cause was thrown away.

Change

parseCloseFrame (RFC 6455 §5.5.1; absent or 1-byte payload → 1005 per §7.4.1), a WSClosedError carrying code and reason (mirroring the existing WSHandshakeError), the close recorded at opcode 0x8 with pending waiters woken, and the result reported from waitUntil's pre-check and from check().

socket.end() remains exactly where it was — connection lifecycle is untouched. TLSWSConnectOptions {port?, ca?} is added only because the real client path is otherwise undrivable against a local server. No close code is auto-skipped.

The serving mock (src/ws-framing.ts) already parses and re-emits close codes correctly and is not in this diff.

Verification

Driven through the real client against a local TLS server using the real computeAcceptKey.

Before, the two cases are the same string:

CASE A (CLOSE 1008 + reason): Error: waitUntil timeout after 1500ms. Collected 0 messages: [] bodies=[]
CASE B (silent):              Error: waitUntil timeout after 1500ms. Collected 0 messages: [] bodies=[]
INDISTINGUISHABLE: true

After:

CASE A: WSClosedError: WebSocket closed by server during waitUntil: code=1008 reason="Requested model is not supported for BidiGenerateContent."…
CASE B: Error: waitUntil timeout after 1500ms. Collected 0 messages: [] bodies=[]
INDISTINGUISHABLE: false

Case B is byte-identical before and after, compared with Buffer.compare rather than by eye. Telling a refusal from a silence is the entire point, so collapsing them in either direction would be worthless. A clean close, a client-initiated close, and answer-then-close (both buffered and same-segment) all remain byte-identical to baseline.

Eleven mutations, all red — including restoring the original bug, collapsing silence into a close, and removing the waiter wake.

Two findings from the mutation round, recorded rather than smoothed over:

M8b passed on the first attempt, which meant the pre-check's scan-before-close ordering was unguarded and a provider that answers then hangs up could have been misreported as a refusal. It now has a test.

The equivalent ordering inside check() is unreachable by construction — the resolver wake sits inside the per-frame parse loop, so an answer sharing a segment with a CLOSE settles the promise before the CLOSE is parsed. The comment there previously sold that ordering as active protection; it now states the ordering is inert, why, and that the close check itself is not dead, so a later reader does not delete a load-bearing branch. Deleting that block reds a test — verified.

Unproven

This does not explain why Gemini Live went silent; it makes the next occurrence explain itself. Whether Gemini Live sends a CLOSE frame at all in that scenario is unverified — if it drops TCP without one, or stalls with the socket open, this reports nothing new. A clean FIN with no CLOSE frame remains indistinguishable from silence: a deliberate scope limit.

@pkg-pr-new

pkg-pr-new Bot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@copilotkit/aimock@373

commit: 23f233c

@jpr5

jpr5 commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

Folded into #371 — one PR for this work instead of three.

@jpr5 jpr5 closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant