From 704ac2428c3c019e3af283b1fccca8aa8b231474 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Sat, 15 Aug 2026 09:06:14 +0800 Subject: [PATCH] fix(keyboard): avoid exclusivity trap during voice cancellation Register cancelled utterances before starting the watchdog so overlapping stored-property access cannot crash the keyboard. --- .../Services/KeyboardFlowCoordinator.swift | 56 ++++++++++++------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift index 0e52882..0f1f4af 100644 --- a/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift +++ b/OSGKeyboardExt/Services/KeyboardFlowCoordinator.swift @@ -17,6 +17,16 @@ final class KeyboardFlowCoordinator { } } + private enum VoiceCancellationKind { + case dictation + case aiQuestion + } + + private enum ResultWatchdogPurpose { + case delivery + case cancellation + } + private let state: KeyboardState private let textInserter: KeyboardTextInserter private let hasFullAccess: () -> Bool @@ -369,7 +379,7 @@ final class KeyboardFlowCoordinator { writeCommand(.abort) isFlowRecording = false isAwaitingFlowResult = true - startFlowResultWatchdog() + startFlowResultWatchdog(purpose: .cancellation) traceState( "orphanedEdit.failClosed", extra: "utterance=\(busyID.uuidString.prefix(8))" @@ -692,7 +702,7 @@ final class KeyboardFlowCoordinator { func cancelAIRecording() { guard currentUtteranceRequest?.isAIQuestion == true else { return } prepareLocalCancel() - beginAwaitingAbort(tracking: &cancelledAIUtteranceIDs) + beginAwaitingAbort(kind: .aiQuestion) } func endAIConversation(_ conversationID: UUID) { @@ -727,7 +737,7 @@ final class KeyboardFlowCoordinator { prepareLocalCancel() let hadIssuedTransport = currentUtteranceId != nil && (isFlowRecording || isAwaitingFlowResult) - beginAwaitingAbort(tracking: &cancelledDictationUtteranceIDs) + beginAwaitingAbort(kind: .dictation) traceState( "dictation.cancelled", extra: hadIssuedTransport @@ -753,26 +763,41 @@ final class KeyboardFlowCoordinator { } /// Keep the cancel chrome (X + white mic) until the host finishes abort. - private func beginAwaitingAbort(tracking cancelledIDs: inout Set) { + private func beginAwaitingAbort(kind: VoiceCancellationKind) { guard let utteranceID = currentUtteranceId, isFlowRecording || isAwaitingFlowResult else { finishLocalCancel() return } - if cancelledIDs.contains(utteranceID), isAwaitingFlowResult { + let inserted = registerCancelledUtterance(utteranceID, kind: kind) + if !inserted, isAwaitingFlowResult { state.phase = .processing recomputeMicVoiceAvailability() return } - cancelledIDs.insert(utteranceID) writeCommand(.abort) isFlowRecording = false isAwaitingFlowResult = true state.phase = .processing - startFlowResultWatchdog() + startFlowResultWatchdog(purpose: .cancellation) recomputeMicVoiceAvailability() } + /// Register cancellation before starting the watchdog. Keeping the mutation + /// in this leaf method prevents a stored-property `inout` access from + /// overlapping watchdog reads and triggering Swift's exclusivity trap. + private func registerCancelledUtterance( + _ utteranceID: UUID, + kind: VoiceCancellationKind + ) -> Bool { + switch kind { + case .dictation: + return cancelledDictationUtteranceIDs.insert(utteranceID).inserted + case .aiQuestion: + return cancelledAIUtteranceIDs.insert(utteranceID).inserted + } + } + private func finishLocalCancel() { clearUnissuedUtterance() isFlowRecording = false @@ -802,7 +827,7 @@ final class KeyboardFlowCoordinator { ExtensionScreenWakeLock.release() state.phase = .idle state.lastTranscript = "" - startFlowResultWatchdog() + startFlowResultWatchdog(purpose: .cancellation) return } resetEditTransportState() @@ -2027,21 +2052,14 @@ final class KeyboardFlowCoordinator { } } - private func startFlowResultWatchdog() { + private func startFlowResultWatchdog( + purpose: ResultWatchdogPurpose = .delivery + ) { stopFlowWatchdog() isAwaitingFlowResult = true let startedAt = Date().timeIntervalSince1970 - let isCancelledEdit = currentUtteranceId.map { - cancelledEditUtteranceIDs.contains($0) - } ?? false - let isCancelledDictation = currentUtteranceId.map { - cancelledDictationUtteranceIDs.contains($0) - } ?? false - let isCancelledAI = currentUtteranceId.map { - cancelledAIUtteranceIDs.contains($0) - } ?? false let resultTimeout: TimeInterval - if isCancelledEdit || isCancelledDictation || isCancelledAI { + if purpose == .cancellation { resultTimeout = FlowSessionKeys.utteranceStartBudget } else if currentUtteranceRequest?.isAIQuestion == true { resultTimeout = FlowSessionKeys.keyboardAIResultTimeout(