[fix] enforce a single voice-typing session (abort stale task/socket) - #107
Merged
Merged
Conversation
Rapid push-to-talk presses could stack transcripts: MicCoordinator
guarantees at most one CAPTURE, but the STT session task it feeds (the
provider WebSocket) was fire-and-forget. After release it lingers to
flush final tokens — by design — but a server that never closes leaves
it parked forever with an open socket, and every session reuses the
same `voice-typing-{n}` / `voice-typing-tail` segment-id namespace. A
new press then opened a second socket while the zombie kept emitting:
the overlay showed both sessions' tokens interleaved.
Backend — new VoiceTypingState singleton guard (managed state):
- start_voice_typing releases any stale voice-typing mic claim and
ABORTS the previous session task (closing its socket) before opening
a fresh one; the new task handle is pinned under a seq counter so a
racing start can never clobber or kill a newer session.
- stop_voice_typing keeps the graceful gate-based stop (final flush
preserved) and adds stop_meeting's direct-cancel safety net: an 8s
seq-guarded abort backstop, so a stalled provider/relay can no longer
leave a zombie session behind.
Frontend (host.ts) — two rapid-press races with the same smell:
- Press/release events are now serialized through a promise chain. A
quick tap used to deliver `stop_voice_typing` to Rust BEFORE its
matching start resolved, so the stop no-op'd and the session it meant
to end stayed alive, ownerless (mic claimed, socket open).
- A press during the previous dictation's settle window used to be
swallowed (busy guard) — the user talked into nothing. It now cuts
the settle short, delivers the pending text, and starts fresh.
Review findings on the singleton PR: - A finalize still awaiting its copy/paste when a NEW press starts a session must not run its tail against the new overlay (emit "done" + schedule hide 1.1s into the live dictation). finalize now captures a session generation at entry and skips the tail if a newer session started; the pending text is still delivered. - Comments: acknowledge the usage-emit trade-off of abort-on-restart, and correct the AlreadyActive "unreachable" rationale (host-side serialization, not the preceding stop).
|
This was referenced Sep 23, 2026
YJack0000
added a commit
that referenced
this pull request
Sep 24, 2026
…the desktop (#419) * [fix] a previous dictation's tail can land in the next one: the failing tests Two tests, both red on this commit, one per layer the rule lives in. `voice_typing.rs`: `VoiceTypingState` gains `open_session`, `adopt`, and `abort_if_current`, the same abort-only gate `start_voice_typing` and the backstops ran inline. `session_gate_tests` adopts a task that is mid-poll when the next session opens and asserts it emits nothing afterwards. It fails with `left: Some("tail final") right: None`. `transcript.ts`: the overlay's segment folding (committed runs by id, the tentative tail, the per-run conversion cache) moves out of `VoiceTypingApp.tsx` into `SessionTranscript`, unchanged. Its test resets for session 2, feeds session 1's flushed final, and expects an empty display. It fails with `expected '第一句的尾巴' to be ''`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * [fix] a previous dictation's tail no longer lands in the next one on the desktop Restarting voice typing within a second or two of stopping it could show the previous dictation's text for a moment and paste it along with the new one. `start_voice_typing` aborted the previous session task without waiting for it, the host reset the overlay before that command even ran, and no event said which session it came from, so the previous task's flushed final landed in the reset overlay under the same `voice-typing-N` id the new session was about to use. #107 closed the two-sockets case and left this window open. One owner token now. `VoiceTypingState.open_session` aborts the previous task, waits up to `RETIRE_GRACE` for the poll it was in to finish, and only then announces the new session id: `start_voice_typing` sends the overlay's `start` reset from that announcement, before it spawns the new task. `run_metered_session` scopes the id into a tokio task-local, so every `transcript://segment`, `audio://level`, `voicetyping://error`, and `stt://closed` the task emits carries `session` without threading it through the adapters; a voice-typing event emitted outside that scope logs an error. The overlay's `SessionTranscript` drops anything from an older session and withholds a report whose conversion a reset overtook, its `voicetyping://text` report carries the session, and it reports an empty text on every reset. The host's `SessionOwner` disowns everything from the press until Rust names the new session, so a late error, close, or text report from the previous one is dropped instead of taken for the new one's. The backstops only abort through the handle; the next start is the one that takes and awaits it. A stop is unchanged: the task keeps flushing under its own session id and the host still pastes the tail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * [chore] mark SessionTranscript's caches readonly and use an optional chain Sonar's new-code gate flagged the three lines on #419. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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.



Problem
Rapid push-to-talk presses could stack transcripts: old tokens interleaving with the new dictation in the overlay.
MicCoordinatorguarantees at most one capture, but the STT session task it feeds (the provider WebSocket) was fire-and-forget — never tracked, never aborted. After release the session lingers to flush its final tokens (by design), but a provider/relay that never closes the socket leaves the task parked on its read half forever, with the socket open. Every session also reuses the samevoice-typing-{n}/voice-typing-tailsegment-id namespace, so when a new press opened a second socket while a zombie was still emitting, the overlay rendered both sessions' tokens interleaved. Each additional press could add another socket.Fix — make the session a singleton
Backend — new
VoiceTypingStatemanaged state pins the one live session task:start_voice_typingreleases any stale voice-typing mic claim and aborts the previous session task (closing its socket) before opening a fresh one. Keydown now always yields a fresh session. The new handle is stored under aseqcounter so a racing start can never clobber — or kill — a newer session than its own.stop_voice_typingkeeps the graceful gate-based stop (final-token flush preserved) and adds the same direct-cancel safety netstop_meetingalready has: an 8s abort backstop (seq-guarded), so a stalled provider can no longer leave a zombie session + open socket behind.Frontend (
host.ts) — two rapid-press races with the same smell, both pre-existing (the first was flagged by #104's independent review):stop_voice_typingahead of its still-in-flightstart_voice_typing, so the stop no-op'd in Rust and the session it meant to end stayed alive, ownerless (mic claimed, socket open, hosted seconds billed).busyguard — the user talked into nothing. It now cuts the settle short, delivers the pending text, and starts fresh (the backend abort guarantees the old session can't leak tokens into the new overlay).Testing
cargo clippy --all-targetscleantsc --noEmitcleanvitest96/96Follow-up to #104/#105/#106.