fix(keyboard): avoid exclusivity trap during voice cancellation

Register cancelled utterances before starting the watchdog so overlapping stored-property access cannot crash the keyboard.
This commit is contained in:
Rocky
2026-08-15 09:06:14 +08:00
parent ed90b9a6dc
commit 704ac2428c
@@ -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 state: KeyboardState
private let textInserter: KeyboardTextInserter private let textInserter: KeyboardTextInserter
private let hasFullAccess: () -> Bool private let hasFullAccess: () -> Bool
@@ -369,7 +379,7 @@ final class KeyboardFlowCoordinator {
writeCommand(.abort) writeCommand(.abort)
isFlowRecording = false isFlowRecording = false
isAwaitingFlowResult = true isAwaitingFlowResult = true
startFlowResultWatchdog() startFlowResultWatchdog(purpose: .cancellation)
traceState( traceState(
"orphanedEdit.failClosed", "orphanedEdit.failClosed",
extra: "utterance=\(busyID.uuidString.prefix(8))" extra: "utterance=\(busyID.uuidString.prefix(8))"
@@ -692,7 +702,7 @@ final class KeyboardFlowCoordinator {
func cancelAIRecording() { func cancelAIRecording() {
guard currentUtteranceRequest?.isAIQuestion == true else { return } guard currentUtteranceRequest?.isAIQuestion == true else { return }
prepareLocalCancel() prepareLocalCancel()
beginAwaitingAbort(tracking: &cancelledAIUtteranceIDs) beginAwaitingAbort(kind: .aiQuestion)
} }
func endAIConversation(_ conversationID: UUID) { func endAIConversation(_ conversationID: UUID) {
@@ -727,7 +737,7 @@ final class KeyboardFlowCoordinator {
prepareLocalCancel() prepareLocalCancel()
let hadIssuedTransport = currentUtteranceId != nil let hadIssuedTransport = currentUtteranceId != nil
&& (isFlowRecording || isAwaitingFlowResult) && (isFlowRecording || isAwaitingFlowResult)
beginAwaitingAbort(tracking: &cancelledDictationUtteranceIDs) beginAwaitingAbort(kind: .dictation)
traceState( traceState(
"dictation.cancelled", "dictation.cancelled",
extra: hadIssuedTransport extra: hadIssuedTransport
@@ -753,26 +763,41 @@ final class KeyboardFlowCoordinator {
} }
/// Keep the cancel chrome (X + white mic) until the host finishes abort. /// Keep the cancel chrome (X + white mic) until the host finishes abort.
private func beginAwaitingAbort(tracking cancelledIDs: inout Set<UUID>) { private func beginAwaitingAbort(kind: VoiceCancellationKind) {
guard let utteranceID = currentUtteranceId, guard let utteranceID = currentUtteranceId,
isFlowRecording || isAwaitingFlowResult else { isFlowRecording || isAwaitingFlowResult else {
finishLocalCancel() finishLocalCancel()
return return
} }
if cancelledIDs.contains(utteranceID), isAwaitingFlowResult { let inserted = registerCancelledUtterance(utteranceID, kind: kind)
if !inserted, isAwaitingFlowResult {
state.phase = .processing state.phase = .processing
recomputeMicVoiceAvailability() recomputeMicVoiceAvailability()
return return
} }
cancelledIDs.insert(utteranceID)
writeCommand(.abort) writeCommand(.abort)
isFlowRecording = false isFlowRecording = false
isAwaitingFlowResult = true isAwaitingFlowResult = true
state.phase = .processing state.phase = .processing
startFlowResultWatchdog() startFlowResultWatchdog(purpose: .cancellation)
recomputeMicVoiceAvailability() 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() { private func finishLocalCancel() {
clearUnissuedUtterance() clearUnissuedUtterance()
isFlowRecording = false isFlowRecording = false
@@ -802,7 +827,7 @@ final class KeyboardFlowCoordinator {
ExtensionScreenWakeLock.release() ExtensionScreenWakeLock.release()
state.phase = .idle state.phase = .idle
state.lastTranscript = "" state.lastTranscript = ""
startFlowResultWatchdog() startFlowResultWatchdog(purpose: .cancellation)
return return
} }
resetEditTransportState() resetEditTransportState()
@@ -2027,21 +2052,14 @@ final class KeyboardFlowCoordinator {
} }
} }
private func startFlowResultWatchdog() { private func startFlowResultWatchdog(
purpose: ResultWatchdogPurpose = .delivery
) {
stopFlowWatchdog() stopFlowWatchdog()
isAwaitingFlowResult = true isAwaitingFlowResult = true
let startedAt = Date().timeIntervalSince1970 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 let resultTimeout: TimeInterval
if isCancelledEdit || isCancelledDictation || isCancelledAI { if purpose == .cancellation {
resultTimeout = FlowSessionKeys.utteranceStartBudget resultTimeout = FlowSessionKeys.utteranceStartBudget
} else if currentUtteranceRequest?.isAIQuestion == true { } else if currentUtteranceRequest?.isAIQuestion == true {
resultTimeout = FlowSessionKeys.keyboardAIResultTimeout( resultTimeout = FlowSessionKeys.keyboardAIResultTimeout(