Skip to content

[fix] a stale launch, stop, or relay socket stops reaching the next dictation - #418

Merged
YJack0000 merged 3 commits into
mainfrom
ios-dictation-single-owner
Sep 23, 2026
Merged

YJack0000 merged 3 commits into
mainfrom
ios-dictation-single-owner

Conversation

@YJack0000

Copy link
Copy Markdown
Contributor

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 leg before they touch the session (#285, #396). Nothing else in DictationCoordinator did. 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 write relay, 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 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, and two AudioCaptures feeding one bridge. In the package, SttRelayClient.start() never checked for a prior cancel(), 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 same leg test the relay path uses. SttRelayClient.start() throws Spent after finish() or cancel(). The three microphone openers become 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:).

Scope

  • ios/App/Parley/DictationCoordinator.swift. owns(_:) and its checks in begin, launch, stop, and performReconnect. openMicrophone() replaces openMicrophoneInBackground() and the inline openers in launch and beginWindowFromForeground. dismiss() loses its unconditional active = false.
  • ios/ParleyKit/Sources/ParleyKit/SttRelayClient.swift. Spent, the spent lock, and the two checks in start().
  • ios/ParleyKit/Tests/ParleyKitTests/SttRelayClientTests.swift.
  • Already keyed and left alone: relay events in handle(_:from:), redials in performReconnect, capture statuses in handle(capture:from:) by captureGeneration, the polish task by session id, the release and hold tasks by active, and the keyboard's own session matching in drainDownlink, startDictation, and checkLiveness.

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. The mic start took N ms log line loses its background prefix.

Verification

  • xcodebuild -scheme ParleyKit -destination 'platform=macOS' test from ios/ParleyKit: 469 tests, 0 failures. testACancelledClientRefusesToStart fails at the first commit with NSURLErrorDomain Code=-999, which shows the socket was really opened, and passes from the second commit on.
  • The app target does not build on this Mac (no iOS 26.2 platform component), so iOS CI is the build. No simulator or device was available. The coordinator change is reviewed by reading, not run, and the flicker itself was not reproduced here.
  • On a device, with the microphone window off: dictate a sentence, tap stop, wait for the text to land, and within about three seconds tap the mic and dictate again. Before, the second pane could show text that was not yours for a moment, or show "Connection failed" and then go back to listening with nothing arriving. After, the second pane shows only its own words and inserts them once. Then tap the mic and tap stop within a second, before any words arrive, and dictate again. Before, the first pane could flip back to listening after its done. After, it stays done.

Follow-ups

  • The desktop has the same class of hole. src-tauri/src/voice_typing.rs:129-131 aborts the old task without awaiting it, src/lib/voiceTyping/host.ts:347 sends the overlay reset while the start_voice_typing call at :333 is still in flight, and no event carries a session id (transcription/common.rs:300 restarts ids at voice-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, and stop() then cancels the socket, so the relay's flushed tail never reaches runs. The folded partial stands in. This has been so since [feature] voice-typing keyboard: dictate into any app #210.
  • From the comment review: closeMicrophone() does not cancel an in-flight openMicrophone(), the isCapturing rule is spelled out at four call sites, and AudioCapture.deinit does not deactivate the audio session.

🤖 Generated with Claude Code

YJack0000 and others added 3 commits September 24, 2026 04:16
…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>
@github-actions

Copy link
Copy Markdown

✅ SonarQube Quality Gate passed — pathorsAI_parley

0 open issues on this PR.

@YJack0000
YJack0000 merged commit b419fd5 into main Sep 23, 2026
3 checks passed
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>
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