Skip to content

[fix] enforce a single voice-typing session (abort stale task/socket) - #107

Merged
YJack0000 merged 2 commits into
mainfrom
fix/voice-typing-singleton
Jul 4, 2026
Merged

YJack0000 merged 2 commits into
mainfrom
fix/voice-typing-singleton

Conversation

@YJack0000

Copy link
Copy Markdown
Contributor

Problem

Rapid push-to-talk presses could stack transcripts: old tokens interleaving with the new dictation in the overlay.

MicCoordinator guarantees 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 same voice-typing-{n} / voice-typing-tail segment-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 VoiceTypingState managed state pins the one live session task:

  • start_voice_typing releases 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 a seq counter so a racing start can never clobber — or kill — a newer session than its own.
  • stop_voice_typing keeps the graceful gate-based stop (final-token flush preserved) and adds the same direct-cancel safety net stop_meeting already 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):

  • Press/release events are now serialized through a promise chain. A quick tap used to race stop_voice_typing ahead of its still-in-flight start_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).
  • A press during the previous dictation's settle window used to be swallowed by the busy guard — 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-targets clean
  • tsc --noEmit clean
  • vitest 96/96

Follow-up to #104/#105/#106.

YJack0000 added 2 commits July 5, 2026 00:48
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).
@YJack0000
YJack0000 merged commit 5e8325f into main Jul 4, 2026
@sonarqubecloud

sonarqubecloud Bot commented Jul 4, 2026

Copy link
Copy Markdown

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>
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