fix(drift): a refused WS session states its close code instead of looking like silence - #373
Closed
jpr5 wants to merge 5 commits into
Closed
fix(drift): a refused WS session states its close code instead of looking like silence#373jpr5 wants to merge 5 commits into
jpr5 wants to merge 5 commits into
Conversation
commit: |
Contributor
Author
|
Folded into #371 — one PR for this work instead of three. |
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.
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 baresocket.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), aWSClosedErrorcarryingcodeandreason(mirroring the existingWSHandshakeError), the close recorded at opcode0x8with pending waiters woken, and the result reported fromwaitUntil's pre-check and fromcheck().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:
After:
Case B is byte-identical before and after, compared with
Buffer.comparerather 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:
M8bpassed 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.