Skip to content

feat(voice-transport): fail-fast client hardening + canonical transport consolidation (voice plan WS1 Steps 15–18) - #2690

Merged
yixuan-ag2 merged 7 commits into
sonichi:mainfrom
yixuan-ag2:voice/phase2-transport
Aug 7, 2026
Merged

yixuan-ag2 merged 7 commits into
sonichi:mainfrom
yixuan-ag2:voice/phase2-transport

Conversation

@yixuan-ag2

@yixuan-ag2 yixuan-ag2 commented Aug 6, 2026 •

Copy link
Copy Markdown
Collaborator

Part of the voice reliability & onboarding plan (desktop repo: docs/design-voice-reliability-and-onboarding.md §1e, docs/impl-plan-voice-reliability-and-onboarding.md WS1 Steps 15–18 + amendments R10/R11/R12/S6/W5/X6/Z6). #2689 is merged; this branch is rebuilt on its merge and current main, so the PR diff is transport-only.

What changed, and why?

Seven commits, one concern: the web voice client fails fast and predictably, from one canonical transport.

  1. 4aa02b86 — src/web-voice-transport.ts becomes the single canonical client transport (design 1e):
    • 6 s connect timeout with terminal-state latching — the incident class this plan exists for (agent wedged, socket never opens or drops immediately) now surfaces a typed failure instead of "Connecting…" forever.
    • Attempt-generation fencing (R10): every await boundary (AudioContext resume, getUserMedia, mic start) re-checks the attempt generation, so stale continuations can't resurrect a dead attempt.
    • agent.state handling: consumes the voice: agent.state v1 protocol emitter + lifecycle snapshot (phase 1, stacked on #2684) #2689 protocol (connecting/backoff/failed reflected in status detail; upstream: failed is a terminal client teardown), with a 3 s legacy fallback for pre-protocol agents.
    • Typed failure union (R12/X6/Z6): onConnectFailure reports timeout | connect-error | mic-permission | mic-device | mic-other | agent-failed | service-down | client-busy; close codes 4409 (client-busy, W5) and 4410 (superseded-by-takeover → its own terminal superseded status, never a failure callback) are decoded.
    • Step 15 reconciliation: cinny's mute/deafen gates and classifyMicErrorCode merged upstream so the vendored copies can be verbatim again.
  2. c7662cfb — web-client.ts's voice page drops its inline duplicate transport and runs on the served SutandoVoice IIFE (/web-voice-transport.js), Step 16/R11: one implementation, surface concerns only. Two additive public methods (sendTextInput, setPlaybackRate) keep the page at feature parity.
  3. 710b75bd — fix round from adversarial review (blocker): attempt-concluding paths (handleClose all branches, connect timeout, mic-error) now bump the attempt generation — uniform invariant attempt concluded ⇒ generation invalidated. Without it, a server close arriving while the getUserMedia prompt was open let the dead attempt resume to live with the mic captured and the stats timer leaked (repro'd in tests, below). Also: connect()-over-live flushes stale scheduled playback; events-doc correction.
  4. 8e3dedb3 — src/voice-connect-resolver.ts reconciles cinny's tailnet tier ladder upstream (byte-identical to the cinny copy), so both vendored voice files are now verbatim and drift-gated in cinny.
  5. d8947985 — disconnect() returns an awaitable completion that settles after the WebSocket close handshake (bounded for wedged peers), so lease release cannot race transport teardown.
  6. 370e2f58 — every terminal conclusion exposes the same awaitable closeSettled() contract, including timeout, mic failure, server close, 4409/4410, and upstream-failed paths.
  7. a48577fb — removes redundant probe initialization from the reconciled resolver.

What files / sections should reviewers look at first?

  • src/web-voice-transport.ts: the class-header invariants comment, then handleClose / onConnectTimeout / the mic-error catch (the generation-invalidation fix), then decodeCloseInfo (4409/4410).
  • tests/web-voice-transport.test.ts: the "fix round" describe block — the two blocker repros are the spec of the bug.
  • src/web-client.ts: the voice page script block (now plain JS on SutandoVoice, per the CONTRIBUTING embedded-script rule).

What user behavior or bug does this prove?

  • Mic click against a dead/wedged agent: "Connecting…" resolves to a typed error within 6 s (previously indefinite).
  • Agent crash mid-prompt (or 4409 busy rejection): the error stays latched — previously the UI could flip to "Live — speak now" with the mic captured and no socket (OS mic indicator on, nobody listening).
  • A second client while a call is active: decoded client-busy; a takeover supersedes the old client with its own superseded terminal state instead of a spurious error.

Feature/documentation consistency

N/A for docs/ canonical documents — same rationale the reviewers accepted on #2689: docs/catalog.json has no canonical doc for the web voice client; the canonical spec for this cross-repo feature is the desktop repo's docs/design-voice-reliability-and-onboarding.md (§1e), and module-level docs live in the source headers here. docs/voice-agent-test-framework.md (agent-side test framework) is unaffected.

Before/after evidence

New discriminating vectors run against the parent commit (detached worktree at 387539a2, test file only overlaid from 3eff47bf):

$ git worktree add --detach /tmp/wt-prefix 387539a2 && cd /tmp/wt-prefix
$ git checkout 3eff47bf -- tests/web-voice-transport.test.ts
$ npx tsx --test --test-force-exit tests/web-voice-transport.test.ts
✖ attempt conclusion invalidates the generation (fix round — server-close-while-parked repro)
ℹ tests 57
ℹ pass 54
ℹ fail 3
✖ failing tests:
✖ server close (1006) while getUserMedia is parked → closed stays final, grant stopped, no stats leak
✖ close 4409 while getUserMedia is parked → latched client-busy error survives the late grant
✖ a direct second connect() over live flushes the old session's scheduled playback

(--test-force-exit is required before the fix: the leaked statsTimer from the repro hangs the runner — the leak is itself part of the bug.)

Same suite at HEAD:

$ npx tsx --test tests/web-voice-transport.test.ts
ℹ tests 57
ℹ pass 57
ℹ fail 0

What tests did you run?

  • Post-rebuild targeted run: npx --no-install tsx --test tests/web-voice-transport.test.ts tests/web-voice-transport-delivery.test.ts tests/voice-connect-resolver.test.ts — 91/91, 0 fail (transport 70, delivery 9, resolver 12).
  • npx --no-install tsc --noEmit — clean.
  • git diff origin/main...HEAD | bash scripts/review-checks.sh — PASS (hardcoded paths clean).
  • Prior cumulative evidence remains: full TS suite 812/812, npm run build:bundle OK (28 KB IIFE), and the affected Python web-client suites green.

Touching a live path?

The served voice page and its transport are client-side; the delivery suite spawns the real web-client and exercises the serve path (above). The full user-visible round trip (mic click → typed failure / live call on a real agent) runs through the desktop app: it will be verified via the desktop repo's scripts/verify-branch-build.sh DMG pass with this branch pinned — same protocol as the Phase-1 PRs — and the transcript/console evidence will be appended here before this PR leaves draft.

Stacked PR?

Parent #2689 merged as ffc0e0af. This branch was rebuilt on that merge/current main; all seven transport patches are unchanged by range-diff, and the PR diff is now child-only. Next: this PR; bodhi #27 provides the 4409/4410 server half (either order — the client tolerates it absent). The paired cinny PR must re-vendor the rebuilt source tree verbatim.

Hardcoded-path check

git diff origin/main...HEAD | bash scripts/review-checks.sh → PASS on rebuilt head a48577fb.

Manual verification — packaged DMG pass (2026-08-06)

Local DMG with this branch as the engine transport (integration merge @ 4bb2d9a9 + phase-3 preference; bodhi pinned at the dist-rebuilt PR-27 head 9f027da; desktop voice/phase3-control-plane; cinny voice/phase3-enable-flow — whose vendored copy is byte-identical to this branch's file, so every client scenario below exercised this transport verbatim).

Live-path round trips (CONTRIBUTING evidence). Real conversation through the transport, from conversation.sqlite (SELECT ts_unix,kind,substr(text,1,60) FROM voice ORDER BY ts_unix DESC):

1786041790.268|agent|Is there a specific directory or file you'd like to explore?
1786041780.839|agent|Your workspace has twelve top-level items, including tasks,
1786041765.249|agent|[task:task-1786041727122] Your workspace has twelve top-leve
1786041728.845|user|Uh what's in my workspace?
1786041727.125|tool_call|work
1786041708.026|user|Hello. Hello

The tasks/task-1786041727122.txt → results/ file round trip completed in the same exchange — the voice→core bridge through this transport, not just the Gemini loop.

Fail-fast + reconnect (incident class). Three kill -9s of the agent mid-session: the client latched a typed error each time (no self-resurrecting "Live"), and each user-initiated reconnect attached to the replacement within seconds. The [VoiceFailure] classifier wired into transport.onClose boot line confirms the classifier active in the packaged agent.

W5/4409/4410 decode, end-to-end through a real bodhi server (first live proof of the takeover wire contract): with a call active in the app, the webUI second client showed "Voice is in use on another surface — Take over the call here" (4409 busy + affordance, no call stealing); taking over superseded the app client, which rendered its own calm terminal ("Call moved — your voice call was taken over on another surface"), i.e. the 4410 → superseded status path, not an error. Reverse direction also checked: second client busy-rejected, incumbent call unaffected.

Step-16 page on the canonical IIFE: the webUI surface used throughout S6 is the served SutandoVoice transport page from this branch — connect, converse, busy-reject, and take over all through /web-voice-transport.js.

Verification/handoff timing through this transport: stage=verify code=live then enable-voice-handoff … ms=44 / ms=41 (same live attempt handed to the session, no second dial).

🤖 Generated with Claude Code

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Post-review addition 4bb2d9a9: disconnect() now returns Promise<void> resolving when the socket's real close event fires (listener attached before teardown; immediate resolve for no-socket/already-CLOSED; wedge-bounded by new ctor option disconnectCloseTimeoutMs, exported default 1500 ms). Motivation: the desktop plan's T8 amendment requires consumers to await teardown before releasing the connection lease, and the fenced post-disconnect close event was otherwise unobservable. Synchronous S6 behavior (generation bump, single synchronous closed) is byte-for-byte unchanged — purely additive. 5 new tests; transport suite 62/62; full tsx suite 817/817; tsc clean; bundle builds. The paired cinny vendor PR re-vendors at this head.

🤖 Generated with Claude Code

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Review follow-up pushed: 2ee1cd6.

P1 — every terminal conclusion now exposes an awaitable close-handshake completion

trackCloseCompletion() is factored out of disconnect() and installed at every terminal self-close site: the mic-error catch, onConnectTimeout, the pre-open handleSocketError path, the upstream-failed teardown, and disconnect() itself. The public closeSettled() completion is latched before the terminal status / onConnectFailure emits, so a consumer reacting to the terminal event can always obtain it; handleClose latches an already-resolved completion for server-initiated closes. The contract is documented on the public surface.

New test vectors (8 rows):

  • mic-error / connect-timeout / pre-open socket-error / upstream-failed each settle only on the socket close event
  • server-initiated closes (4000 / 4409 / 4410) are already settled at the status emit
  • bounded fallback
  • disconnect-after-latch

web-voice-transport.test.ts: 70/70. Full tsx suite: 825 pass / 0 fail. The sync S6/R10 ordering is unchanged. The paired cinny PRs (ag2-space/cinny-webclient#393 and ag2-space/cinny-webclient#394) re-vendor at this head.

🤖 Generated with Claude Code

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Merge position: Stage 3 — after #2689 (merge commit, not squash). Sibling of #2691 (either order between the two). After this merges, cinny #393 (already re-vendored verbatim at this exact head 2ee1cd6) is safe to merge — if any further commit lands here first, #393 must be re-vendored before merging so the byte-identity holds.

🤖 Generated with Claude Code

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh out of draft, zero prior reviews. Ran everything myself:

  • tests/web-voice-transport.test.ts: 70/70 pass (19 suites).
  • tests/web-voice-transport-delivery.test.ts: 9/9 pass.
  • tests/voice-connect-resolver.test.ts: 12/12 pass.

Specifically verified the claimed bug fix ("attempt concluded ⇒ generation invalidated") against its own repro test - server close (1006) while getUserMedia is parked: server drops the socket while the mic permission prompt is open, user grants late, and the test asserts directly against internal state (micStream === null, statsTimer === null, granted track stopped === true) that the dead attempt never resumes and doesn't leak the mic grant or a stats timer. Non-vacuous - it exercises the real race, not a mocked shortcut.

Didn't deep-read every line of the 26-file diff, but the transport's core invariants (generation fencing, terminal-state latching, close-code decoding for 4409/4410, mic failure classification) are all directly test-covered and green.

@john-the-dev

Copy link
Copy Markdown
Collaborator

Review note (exact head 2ee1cd6590261c5862f79c62bb0653925b4c7c83): I found no new code blocker in the inspected transport/voice changes. Local evidence: npx tsx --test tests/web-voice-transport.test.ts 70/70, tests/voice-connect-resolver.test.ts 12/12, and the repository hardcoded-path review scan PASS. The delivery suite could not complete in this clean worktree because esbuild/tsc are not installed; its spawned serve check consequently returned 503, so that live artifact check is not a code verdict. GitHub currently exposes only CLA, reports BEHIND/REVIEW_REQUIRED, and has one exact-head approval. Please refresh onto current main, rerun the delivery/live and required CI gates after the GitHub outage, and obtain a second current-head maintainer approval before merge.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved current head 2ee1cd6 after exact-head validation: npm run typecheck PASS; voice-connect-resolver 12/12 PASS; web-voice-transport-delivery 9/9 PASS; web-voice-transport 70/70 PASS; voice-error-classifier 15/15 PASS. GitHub currently reports MERGEABLE but BEHIND; required CI is not present on this head, so do not merge until branch is updated and checks pass.

@github-actions

github-actions Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines).

yixuan-ag2 and others added 7 commits August 7, 2026 12:53
…on (WS1 Steps 15+18)

Canonical src/web-voice-transport.ts — plan WS1 Steps 15 and 18 as amended
by R10/R12/S6/W5/X6/Z6; design 1e + 1a-prime client rules.

Step 15 (consolidation i — cinny additions reconciled upstream):
- mute/deafen call controls merged verbatim from the cinny vendored copy:
  micMuted/deafened fields, setMicMuted/setDeafened, the onaudioprocess
  send gate and the playChunk playback gate. Canonical keeps its richer
  two-arg classifyMicError, onDebug, VoiceCloseInfo, and debug counters
  (the canonical module is the superset).
- new export classifyMicErrorCode(name): 'permission'|'device'|'unknown'
  (machine-readable class for provider routing).

Step 18 (design 1e):
- 6s connect timeout (CONNECT_TIMEOUT_MS, constructor-overridable); armed
  in connect(), cleared on open and every terminal path; fire = latched
  terminal error.
- attempt-generation token: every socket callback and every post-await
  continuation (connect's AudioContext.resume, startMic's getUserMedia and
  resume, the post-startMic continuation) re-checks the generation (R10);
  stale grants stop their own tracks — no replacement socket, no capture
  leak.
- disconnect() terminalizes (S6): invalidates the attempt first, clears
  timers, then synchronously emits exactly ONE 'closed' — never dependent
  on the generation-fenced onclose; the terminal-error cleanup path stays
  separate and preserves the latched 'error'.
- terminal-state latching: a timeout/error is terminal for the attempt;
  the self-inflicted close tears audio down but never overwrites 'error'
  with 'closed'.
- agent.state v1 client handling (1a-prime): mic/'Live' stay keyed on
  onopen; 3s legacy window (no frame => behave exactly as today);
  connecting/backoff are progress detail; idle->connecting->live is the
  normal wake-up; upstream:'failed' is a terminal CLIENT transition —
  invalidate, stop mic/stats/playback, close the socket, latch the
  classified error, suppress the self-inflicted onclose. AgentStateV1 is
  duplicated here (vendored standalone) and pinned to the v1 frame.
- exported failure union + onConnectFailure (R12+X6+Z6+W5):
  'timeout'|'connect-error'|'mic-permission'|'mic-device'|'mic-other'|
  'agent-failed'|'service-down'|'client-busy', each with classified detail
  + remediation hint. 'connect-error' is the browser-observable pre-open
  failure ('refused' is NOT browser-observable — Z6); 'service-down' is a
  supervisor-status conclusion produced by surfaces, never by the
  transport; 'mic-other' remediation is Retry, never credential repair
  (X6); close 4409 'client-busy' decodes to kind 'client-busy' (W5).
- close 4410 'superseded-by-takeover' decodes to its own terminal
  VoiceStatus 'superseded' (call moved; not a connect failure).
- testability seam wsFactory + explicit fire-and-forget connect() doc;
  additive sendTextInput/setPlaybackRate for the webUI page dedup.

Tests (tests/web-voice-transport.test.ts): node-driven state machine via
fake sockets + AudioContext/getUserMedia stand-ins — timeout latch, stale
onclose fencing, slow-startMic across attempts, disconnect during each
await, S6 single-closed vectors, legacy-window fallback, backoff/failed
frames, 4409/4410 decoding, mic-failure kind mapping, failure-union
remediation table, mute/deafen gates. 53 tests green; tsc --noEmit clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…WS1 Step 16)

Transport consolidation (ii), plan WS1 Step 16 as amended by R11 — the
webUI page (what the room-Call iframe executes) now inherits the canonical
transport's timeout/latching/agent.state behavior instead of its older
inline copy.

- The page loads the existing served IIFE artifact via a classic
  <script src="/web-voice-transport.js"> (pipeline unchanged — no second
  build path) and instantiates SutandoVoice.VoiceTransport.
- Inline connectWs/startMic/stopMic/playChunk and the PCM DSP copies are
  deleted. The page keeps ONLY surface concerns: transcript DOM, status
  text, stats panel via onStats, avatar analyser via onAnalyser, Chrome
  interim-STT, tool cues, and reconnect policy (clean-vs-drop ladder on
  'closed', exactly as before).
- New failure surface per the canonical union: terminal failures
  (timeout/connect-error/agent-failed/mic-*) reset the voice UI and print
  classified detail + remediation; 'client-busy' renders the W5 take-over
  affordance (reconnect with ?takeover=1); status 'superseded' (close
  4410) renders "call moved" and never auto-reconnects.
- mute button drives transport.setMicMuted; speech_speed frames drive
  setPlaybackRate; the text box sends through sendTextInput while live.
  Embedded page script remains plain JS (no TS-only syntax) — verified by
  classic-script parsing of the served page.
- DSP drift-guard framing retired: the page uses the module directly, so
  equivalence-by-copy is no longer a thing to pin.

Delivery test (R11): asserts the artifact is classic-script IIFE JS
defining SutandoVoice.VoiceTransport — NOT ESM (vm.Script parse + global
install), covers the Step-15/18 export surface, and adds a serve-path
smoke that spawns web-client and asserts GET /web-voice-transport.js
serves that JS and the page references it via a classic script tag.

build:bundle green (28KB browser IIFE); full tsx suite 804/804; web-client
python suites and tsc --noEmit clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eneration

Reviewer-reproduced blocker (voice plan WS1 Steps 15/17/18 fix round): the
attempt-generation token was only bumped by connect(), disconnect(), and the
upstream-failed transition. Every other concluding path — all handleClose
branches (ordinary drop, 4000 goodbye, 4409 client-busy, 4410 superseded,
terminal-latched return), onConnectTimeout, handleOpen's mic-error catch,
and handleSocketError's pre-open branch — latched terminal/attemptActive
only. The post-await fences check gen alone, so a server close arriving
while startMic's getUserMedia await was parked let the dead attempt resume:
status('live') overwrote the close-derived/latched status, the just-granted
mic stream stayed captured, and the statsTimer leaked (the leak literally
keeps the node:test runner alive without --test-force-exit).

Fix: the uniform invariant "attempt concluded => generation invalidated" —
every concluding path now bumps attemptGen exactly as upstream-failed and
disconnect() already did. handleClose bumps once at the top (its closure
fence guarantees it only runs for the current attempt); the terminal-latched
branch keeps teardown as defense in depth. Audit fallout fixed alongside:
the mic-error catch relied on the trailing self-inflicted onclose (now
fenced out) for teardown, so it tears its own audio graph down and clears
the legacy timer itself.

Also in this pass:
- connect()'s new-attempt reset flushes scheduled playback, so a direct
  second connect() over a live session cannot keep speaking old audio into
  the new attempt (nextPlayTime restarts on the new clock);
- onStatus doc: close info accompanies every 'closed' decoded from a WS
  close frame; user-initiated disconnect() emits 'closed' without it.

Tests (fake-socket harness): server 1006 while getUserMedia is parked keeps
'closed' final (no live resurrection, tracks stopped, no statsTimer leak);
4409 while parked keeps the latched client-busy error; R10 vector for
startMic's INTERNAL audioCtx.resume() await (park via resumeHook,
disconnect while parked, newer attempt's micStream never clobbered, exactly
one 'closed'); connect()-over-live flushes playback. Verified the first
three fail against the unfixed transport (3 fail / 54 pass) and the full
suite is 812/812 green with the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
voice plan WS1 Steps 15/17/18 fix round: Step 17 wants
voice-connect-resolver.ts vendorable verbatim, but the cinny copy had grown
local additions upstream never received — the 'tailnet' VoiceTier (mesh-VPN
address, preferred over same-subnet-only lan), directWsUrl() (https ->
wss/:443, http -> ws with the advertised port, bare host -> :proxyPort/ws),
the localPort-vs-proxyPort split (off-localhost tiers dial the webUI /ws
proxy since the voice-agent WS binds loopback-only), and agentEndpoint
identity-routing (dial the room-agent's advertised endpoint, never the
generic local host that would answer as the wrong agent).

This makes the upstream file byte-identical to the cinny copy (verified
with cmp; the cinny content is the newer superset — nothing upstream had is
dropped, and the only behavioral change, lan candidates moving from
ws://host:9900 to ws://host:proxyPort/ws, is the documented loopback-bind
correction with no upstream dependents). New tests pin the reconciled
surface: tailnet slots between local and lan, off-localhost candidates use
the proxy port, directWsUrl's three mappings, and agentEndpoint routing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ake completion (T8)

The transport's disconnect() was fully synchronous: it bumped the attempt
generation, emitted the single synchronous 'closed' (S6), and called
ws.close() — which only STARTS the async WebSocket close handshake; the
socket's real 'close' event is generation-fenced and unobservable to
consumers. Amendment T8 requires lease owners to await teardown before
releasing the connection lease, which needs the handshake to be awaitable.

disconnect() now returns Promise<void> resolving when the underlying
socket's real 'close' event fires. Purely additive:
- the synchronous behavior (gen bump, timer clears, teardownAudio, the
  single sync 'closed' emission) is byte-for-byte unchanged, and the sync
  'closed' always fires before the promise resolves;
- the close listener wraps the socket's `onclose` property — the wsFactory
  seam guarantees only the onopen/onmessage/onerror/onclose + send/close/
  readyState surface (no addEventListener on fakes) — attached BEFORE
  ws.close() and BEFORE this.ws is cleared; the wrapped previous handler
  (connect()'s generation-fenced closure) still runs, a guaranteed no-op
  after the bump;
- no socket, or readyState already CLOSED → immediate resolve;
- a wedged handshake is bounded by new ctor option disconnectCloseTimeoutMs
  (default exported DISCONNECT_CLOSE_TIMEOUT_MS = 1500) so teardown can
  never hang lease release;
- close() alias returns the same promise.

Tests (T8 block): resolves only after the fake socket's delayed close
event (sync 'closed' first, still exactly one 'closed'); close() alias
waits the same way; no socket → immediate; already-CLOSED socket →
immediate; injected short bound → fallback resolves a never-closing
handshake. Full tsx suite 817/817, tsc --noEmit clean, build:bundle OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…close-handshake completion (P1)

Transport-initiated TERMINAL failures (connect timeout, mic failure while
the socket is open, pre-open socket error, upstream-failed teardown) used
to latch + emit onConnectFailure and only then self-close the socket, with
no way for consumers to know when the close handshake actually finished.
A consumer releasing a single-client lease inside onConnectFailure raced
the handshake: the server still counted the old client and rejected the
next attempt with a spurious 4409.

- Factor disconnect()'s close-completion tracking (onclose property wrap,
  immediate resolve for no-socket/CLOSED, bounded by
  disconnectCloseTimeoutMs) into a private trackCloseCompletion() helper
  and use it at EVERY self-initiated terminal close site: the mic-error
  catch, onConnectTimeout, the pre-open handleSocketError branch, the
  upstream-failed teardown, and disconnect() itself.
- New public closeSettled(): the CURRENT attempt-conclusion close
  completion, latched BEFORE the terminal status/onConnectFailure emits so
  it can be read from inside the callbacks. Close-derived conclusions
  (handleClose: plain close, 4409, 4410, pre-open close) latch an
  already-resolved completion — the socket is already closed there.
- disconnect() with no socket now returns the current conclusion's
  completion, so `await disconnect()` after a terminal latch still covers
  the in-flight handshake of the socket the latch already closed.
- Synchronous observable ordering (S6/R10 latching, single closed
  emission, generation bumps) is unchanged.
- Tests: closeSettled timing rows for mic-error / timeout / pre-open
  error / upstream-failed (settle only on the socket's real close event),
  already-settled rows for server-initiated closes, the bounded fallback,
  and disconnect()-after-latch coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@yixuan-ag2
yixuan-ag2 force-pushed the voice/phase2-transport branch from 40eb8e6 to a48577f Compare August 7, 2026 19:54
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

Stack rebuild complete at a48577fb after #2689 merged. The branch is now transport-only on current main; all seven patches are unchanged by range-diff. Local validation: transport/delivery/resolver 91/91, tsc --noEmit clean, hardcoded-path gate PASS. CI is running. Prior approvals predate this rebuilt head, so please re-review exact head a48577fb once checks are green.

@yixuan-ag2
yixuan-ag2 merged commit 4c79ccd into sonichi:main Aug 7, 2026
17 checks passed
@yixuan-ag2
yixuan-ag2 deleted the voice/phase2-transport branch August 7, 2026 20:35
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.

3 participants