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:
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user