[fix] a stale launch, stop, or relay socket stops reaching the next dictation - #418
Merged
Merged
Conversation
…cket (failing test) `SttRelayClient.cancel()` runs `tearDown` on a detached task, and `start()` never checks whether that already happened. A client cancelled while its owner was still waiting on the microphone went on to connect, send the config frame and start its keepalive loop, and nothing ever closed it. The test asserts the refusal `Spent`, which this commit declares and does not yet throw: today the attempt reaches the network and fails with a transport error instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ncelled `start()` now throws `Spent` when `finish()` or `cancel()` came first, and again if a cancel lands during the handshake. The flag is a lock rather than actor state because `cancel()` is synchronous: an owner that cancels a client whose `start()` is still in flight needs the refusal to hold from the moment `cancel()` returns, not from whenever the detached `tearDown` gets the actor. Before this, a dictation stopped while `launch` was still waiting on the microphone had its client cancelled by `finishUp`, and that client then finished connecting anyway: config frame sent, keepalive running, socket open until the relay idled it out. That is a second live socket on the next dictation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every relay event and every redial already checks `leg` before it touches the session (#285, #396). Nothing else in the coordinator did. `launch()` resumes after the permission prompt, the microphone start and the socket handshake; `stop()` resumes after the finalize; `begin()` resumes after the stop it ran for the previous session. Each of them went on to write `relay`, `capture`, `state` and the downlink as if it still owned the session, and a session that had ended or been replaced during the wait paid for it: a `.listening` published over a `done`, a `Connection failed` published under the next session's id, the next session's relay cancelled and its transcript settled as `done`, or a second `AudioCapture` feeding the bridge alongside the first. Now every continuation checks `owns(owner)`, the same `leg` test the relay path uses, before it touches anything. `dismiss()` loses its unconditional `active = false`, which could have flipped a newer session's flag. The three microphone openers collapse into `openMicrophone()`, which joins an open already in flight instead of starting a second one. That is the overlap #404 documented and left to `begin(session:)`: the keyboard's URL fallback fires 700 ms into a background microphone start, and the foreground `launch` it triggers found `capture == nil` and opened another. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
✅ SonarQube Quality Gate passed — pathorsAI_parley0 open issues on this PR. |
YJack0000
added a commit
that referenced
this pull request
Sep 24, 2026
…rds, and the voice pane swipes from anywhere (#422) Version and build bump on the app, keyboard and Live Activity targets, and What's New for 1.19 in both locales. 1.18 (build 33) went to TestFlight but never to the App Store, so the 1.19 notes also carry 1.18's two changes for people updating from 1.17. New since build 33: #418 (a stale launch, stop or relay socket no longer reaches the next dictation) and #420 (the voice pane swipes from its empty space). Co-authored-by: Claude Opus 5.5 <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.
Why
A second dictation started a few seconds after the first one stopped sometimes shows other text for a moment before the real transcript lands. The last time this class of bug was chased, two relay sockets were transcribing at once.
Relay events and redials already check
legbefore they touch the session (#285, #396). Nothing else inDictationCoordinatordid.launch()resumes after the permission prompt, the microphone start, and the socket handshake.stop()resumes after the finalize.begin(session:)resumes after the stop it ran for the previous session. Each went on to writerelay,capture,state, and the downlink as if it still owned the session. When the session had ended or been replaced during the wait, the next session paid for it. The traced outcomes are alisteningpublished over adone, aConnection failedpublished under the next session's id, the next session's relay cancelled and its transcript settled asdone, and twoAudioCaptures feeding one bridge. In the package,SttRelayClient.start()never checked for a priorcancel(), so a client cancelled mid-handshake finished connecting and kept its keepalive running with nothing owning it. That is a second live socket on the next dictation.The fix is one rule rather than new flags. Every continuation checks
owns(owner), the samelegtest the relay path uses.SttRelayClient.start()throwsSpentafterfinish()orcancel(). The three microphone openers becomeopenMicrophone(), which joins an open already in flight instead of starting a second one. That is the overlap #404 documented and left tobegin(session:).Scope
ios/App/Parley/DictationCoordinator.swift.owns(_:)and its checks inbegin,launch,stop, andperformReconnect.openMicrophone()replacesopenMicrophoneInBackground()and the inline openers inlaunchandbeginWindowFromForeground.dismiss()loses its unconditionalactive = false.ios/ParleyKit/Sources/ParleyKit/SttRelayClient.swift.Spent, thespentlock, and the two checks instart().ios/ParleyKit/Tests/ParleyKitTests/SttRelayClientTests.swift.handle(_:from:), redials inperformReconnect, capture statuses inhandle(capture:from:)bycaptureGeneration, the polish task by session id, the release and hold tasks byactive, and the keyboard's own session matching indrainDownlink,startDictation, andcheckLiveness.Blast radius
Every dictation start and stop on iOS, and every
SttRelayClient(dictation and meetings). A session that ends normally takes the same path as before. The new checks fire only when a continuation outlives its session. Themic start took N mslog line loses itsbackgroundprefix.Verification
xcodebuild -scheme ParleyKit -destination 'platform=macOS' testfromios/ParleyKit: 469 tests, 0 failures.testACancelledClientRefusesToStartfails at the first commit withNSURLErrorDomain Code=-999, which shows the socket was really opened, and passes from the second commit on.done. After, it stays done.Follow-ups
src-tauri/src/voice_typing.rs:129-131aborts the old task without awaiting it,src/lib/voiceTyping/host.ts:347sends the overlay reset while thestart_voice_typingcall at:333is still in flight, and no event carries a session id (transcription/common.rs:300restarts ids atvoice-typing-0). A tail final from session 1 can land after the overlay's reset and end up in session 2's paste. [fix] enforce a single voice-typing session (abort stale task/socket) #107 left this open. The fix is to await the aborted task, send the reset from Rust after it, and publish an empty text on reset. Not done here.SttRelayClient.finish()returns once the finalize frame is sent, andstop()then cancels the socket, so the relay's flushed tail never reachesruns. The foldedpartialstands in. This has been so since [feature] voice-typing keyboard: dictate into any app #210.closeMicrophone()does not cancel an in-flightopenMicrophone(), theisCapturingrule is spelled out at four call sites, andAudioCapture.deinitdoes not deactivate the audio session.🤖 Generated with Claude Code