From ea5832f312f842a208ae630ea7463195756639fa Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 31 Aug 2026 13:20:33 -0400 Subject: [PATCH 1/9] Add hedged transcription: race the recovery fallback when the primary engine is slow A slow-but-alive primary engine previously had no remedy: automatic recovery fallback only fires on a hard error, so a degraded provider meant waiting out the full request. This adds an opt-in hedge to the Automatic Fallback feature: when the primary transcription has not answered within a configurable threshold, the same audio is dispatched to the recovery fallback engine in parallel; whichever result arrives first wins and the losing request is cancelled. - The race lives in DictationViewModel.transcribeFinalAudio via a task group; a primary failure before the hedge fires is rethrown so the existing sequential error-path fallback applies unchanged, and a primary failure after the hedge fires awaits the in-flight fallback. - Off by default; the toggle and threshold stepper sit in the Dictation Recovery settings under Automatic Fallback, gated on the same license and engine-configuration checks, with a note that a dispatched race costs one extra API call. - The primary engine call is now routed through an injectable PrimaryTranscriptionRunner (mirroring RecoveryFallbackRunner), which keeps the race deterministic under test. - New settings are included in settings backup/restore. Co-Authored-By: Claude Fable 5 --- TypeWhisper/App/ServiceContainer.swift | 3 + TypeWhisper/App/UserDefaultsKeys.swift | 2 + .../Services/SettingsBackupExporter.swift | 8 + .../DictationRecoveryViewModel.swift | 22 ++ .../ViewModels/DictationViewModel.swift | 238 +++++++++++++++++- TypeWhisper/Views/DictationRecoveryView.swift | 33 +++ .../TypeWhisperIntegrationTests.swift | 159 ++++++++++++ 7 files changed, 454 insertions(+), 11 deletions(-) diff --git a/TypeWhisper/App/ServiceContainer.swift b/TypeWhisper/App/ServiceContainer.swift index 59eeafb7c..3715d0d75 100644 --- a/TypeWhisper/App/ServiceContainer.swift +++ b/TypeWhisper/App/ServiceContainer.swift @@ -216,6 +216,9 @@ final class ServiceContainer: ObservableObject { excluding: primaryEngineId, task: task ) + }, + recoveryHedgeThresholdProvider: { [recoveryViewModel] in + recoveryViewModel.automaticHedgeThreshold } ) audioRecorderViewModel = AudioRecorderViewModel( diff --git a/TypeWhisper/App/UserDefaultsKeys.swift b/TypeWhisper/App/UserDefaultsKeys.swift index c773b834c..f47b09c57 100644 --- a/TypeWhisper/App/UserDefaultsKeys.swift +++ b/TypeWhisper/App/UserDefaultsKeys.swift @@ -150,6 +150,8 @@ enum UserDefaultsKeys { static let dictationRecoveryModel = "dictationRecoveryModel" static let dictationRecoveryLanguage = "dictationRecoveryLanguage" static let dictationRecoveryAutomaticFallbackEnabled = "dictationRecoveryAutomaticFallbackEnabled" + static let dictationRecoveryHedgeEnabled = "dictationRecoveryHedgeEnabled" + static let dictationRecoveryHedgeThresholdSeconds = "dictationRecoveryHedgeThresholdSeconds" static let dictationRecoveryRetentionDays = "dictationRecoveryRetentionDays" // MARK: - Watch Folder diff --git a/TypeWhisper/Services/SettingsBackupExporter.swift b/TypeWhisper/Services/SettingsBackupExporter.swift index cd57b731b..fa5268a72 100644 --- a/TypeWhisper/Services/SettingsBackupExporter.swift +++ b/TypeWhisper/Services/SettingsBackupExporter.swift @@ -183,6 +183,8 @@ enum SettingsBackupExporter { // Dictation Recovery var dictationRecoveryLanguage: String? = nil var dictationRecoveryAutomaticFallbackEnabled: Bool? = nil + var dictationRecoveryHedgeEnabled: Bool? = nil + var dictationRecoveryHedgeThresholdSeconds: Double? = nil var dictationRecoveryRetentionDays: Int? = nil // File Transcription var fileTranscriptionLanguage: String? = nil @@ -230,6 +232,8 @@ enum SettingsBackupExporter { if requireSecondEscapeToCancelRecording != nil { count += 1 } if dictationRecoveryLanguage != nil { count += 1 } if dictationRecoveryAutomaticFallbackEnabled != nil { count += 1 } + if dictationRecoveryHedgeEnabled != nil { count += 1 } + if dictationRecoveryHedgeThresholdSeconds != nil { count += 1 } if dictationRecoveryRetentionDays != nil { count += 1 } if fileTranscriptionLanguage != nil { count += 1 } if recorderMicEnabled != nil { count += 1 } @@ -576,6 +580,8 @@ enum SettingsBackupExporter { requireSecondEscapeToCancelRecording: userDefaults.object(forKey: UserDefaultsKeys.requireSecondEscapeToCancelRecording) as? Bool, dictationRecoveryLanguage: userDefaults.string(forKey: UserDefaultsKeys.dictationRecoveryLanguage), dictationRecoveryAutomaticFallbackEnabled: userDefaults.object(forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled) as? Bool, + dictationRecoveryHedgeEnabled: userDefaults.object(forKey: UserDefaultsKeys.dictationRecoveryHedgeEnabled) as? Bool, + dictationRecoveryHedgeThresholdSeconds: userDefaults.object(forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) as? Double, dictationRecoveryRetentionDays: userDefaults.object(forKey: UserDefaultsKeys.dictationRecoveryRetentionDays) == nil ? nil : DictationRecoveryRetentionPolicy.load(from: userDefaults).rawValue, @@ -857,6 +863,8 @@ enum SettingsBackupExporter { apply(preferences.requireSecondEscapeToCancelRecording, forKey: UserDefaultsKeys.requireSecondEscapeToCancelRecording) apply(preferences.dictationRecoveryLanguage, forKey: UserDefaultsKeys.dictationRecoveryLanguage) apply(preferences.dictationRecoveryAutomaticFallbackEnabled, forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled) + apply(preferences.dictationRecoveryHedgeEnabled, forKey: UserDefaultsKeys.dictationRecoveryHedgeEnabled) + apply(preferences.dictationRecoveryHedgeThresholdSeconds, forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) apply(preferences.dictationRecoveryRetentionDays, forKey: UserDefaultsKeys.dictationRecoveryRetentionDays) if preferences.dictationRecoveryRetentionDays != nil { recoveryRetentionPolicyDidChange?(DictationRecoveryRetentionPolicy.load(from: userDefaults)) diff --git a/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift b/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift index e45f8d05c..8d26e0b78 100644 --- a/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift +++ b/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift @@ -76,6 +76,25 @@ final class DictationRecoveryViewModel: ObservableObject { defaults.set(automaticFallbackEnabled, forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled) } } + @Published var hedgeEnabled: Bool { + didSet { + defaults.set(hedgeEnabled, forKey: UserDefaultsKeys.dictationRecoveryHedgeEnabled) + } + } + @Published var hedgeThresholdSeconds: Double { + didSet { + defaults.set(hedgeThresholdSeconds, forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) + } + } + + /// Threshold after which a still-running primary transcription should race the + /// recovery fallback engine, or nil when hedging is off. The engine/licensing + /// gates live in `automaticFallbackConfiguration` — hedging only activates when + /// that returns a configuration. + var automaticHedgeThreshold: TimeInterval? { + guard hedgeEnabled, automaticFallbackEnabled, hedgeThresholdSeconds > 0 else { return nil } + return hedgeThresholdSeconds + } @Published var retentionPolicy: DictationRecoveryRetentionPolicy { didSet { defaults.set(retentionPolicy.rawValue, forKey: UserDefaultsKeys.dictationRecoveryRetentionDays) @@ -138,6 +157,9 @@ final class DictationRecoveryViewModel: ObservableObject { self.selectedEngine = defaults.string(forKey: UserDefaultsKeys.dictationRecoveryEngine) self.selectedModel = defaults.string(forKey: UserDefaultsKeys.dictationRecoveryModel) self.automaticFallbackEnabled = defaults.bool(forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled) + self.hedgeEnabled = defaults.bool(forKey: UserDefaultsKeys.dictationRecoveryHedgeEnabled) + let storedHedgeThreshold = defaults.double(forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) + self.hedgeThresholdSeconds = storedHedgeThreshold > 0 ? storedHedgeThreshold : 3.0 self.retentionPolicy = retentionPolicy self.isInitialized = true diff --git a/TypeWhisper/ViewModels/DictationViewModel.swift b/TypeWhisper/ViewModels/DictationViewModel.swift index 2082b0a50..4a71c65c3 100644 --- a/TypeWhisper/ViewModels/DictationViewModel.swift +++ b/TypeWhisper/ViewModels/DictationViewModel.swift @@ -131,6 +131,17 @@ final class DictationViewModel: ObservableObject { _ dictionaryTermHints: [PluginDictionaryTermHint], _ normalizeNumbers: Bool? ) async throws -> TranscriptionResult + typealias RecoveryHedgeThresholdProvider = @MainActor () -> TimeInterval? + typealias PrimaryTranscriptionRunner = @MainActor ( + _ samples: [Float], + _ languageSelection: LanguageSelection, + _ task: TranscriptionTask, + _ engineOverrideId: String?, + _ cloudModelOverride: String?, + _ prompt: String?, + _ dictionaryTermHints: [PluginDictionaryTermHint], + _ normalizeNumbers: Bool? + ) async throws -> TranscriptionResult private struct FinalTranscriptionOutput { let result: TranscriptionResult @@ -321,6 +332,8 @@ final class DictationViewModel: ObservableObject { private let postProcessingPipeline: PostProcessingPipeline private let recoveryFallbackConfigurationProvider: RecoveryFallbackConfigurationProvider private let recoveryFallbackRunner: RecoveryFallbackRunner + private let recoveryHedgeThresholdProvider: RecoveryHedgeThresholdProvider + private let primaryTranscriptionRunner: PrimaryTranscriptionRunner private var matchedWorkflow: Workflow? private var activeWorkflowMatch: WorkflowMatchResult? private var forcedWorkflowId: UUID? @@ -421,7 +434,9 @@ final class DictationViewModel: ObservableObject { mediaPlaybackService: MediaPlaybackService, usageStatisticsRecorder: UsageStatisticsRecording? = nil, recoveryFallbackConfigurationProvider: RecoveryFallbackConfigurationProvider? = nil, - recoveryFallbackRunner: RecoveryFallbackRunner? = nil + recoveryFallbackRunner: RecoveryFallbackRunner? = nil, + recoveryHedgeThresholdProvider: RecoveryHedgeThresholdProvider? = nil, + primaryTranscriptionRunner: PrimaryTranscriptionRunner? = nil ) { self.audioRecordingService = audioRecordingService self.textInsertionService = textInsertionService @@ -459,6 +474,19 @@ final class DictationViewModel: ObservableObject { self.errorLogService = errorLogService self.mediaPlaybackService = mediaPlaybackService self.recoveryFallbackConfigurationProvider = recoveryFallbackConfigurationProvider ?? { _, _ in nil } + self.recoveryHedgeThresholdProvider = recoveryHedgeThresholdProvider ?? { nil } + self.primaryTranscriptionRunner = primaryTranscriptionRunner ?? { [modelManager] samples, languageSelection, task, engineOverrideId, cloudModelOverride, prompt, dictionaryTermHints, normalizeNumbers in + try await modelManager.transcribe( + audioSamples: samples, + languageSelection: languageSelection, + task: task, + engineOverrideId: engineOverrideId, + cloudModelOverride: cloudModelOverride, + prompt: prompt, + dictionaryTermHints: dictionaryTermHints, + normalizeNumbers: normalizeNumbers + ) + } self.recoveryFallbackRunner = recoveryFallbackRunner ?? { [modelManager] samples, languageSelection, task, configuration, prompt, dictionaryTermHints, normalizeNumbers in try await modelManager.transcribe( audioSamples: samples, @@ -2220,16 +2248,32 @@ final class DictationViewModel: ObservableObject { dictionaryTermHints: [PluginDictionaryTermHint], normalizeNumbers: Bool? ) async throws -> FinalTranscriptionOutput { + let fallbackConfiguration = recoveryFallbackConfigurationProvider(primaryEngineId, task) do { - let result = try await modelManager.transcribe( - audioSamples: audioSamples, - languageSelection: languageSelection, - task: task, - engineOverrideId: primaryEngineId, - cloudModelOverride: primaryCloudModelOverride, - prompt: prompt, - dictionaryTermHints: dictionaryTermHints, - normalizeNumbers: normalizeNumbers + if let configuration = fallbackConfiguration, + let hedgeThreshold = recoveryHedgeThresholdProvider() { + return try await hedgedTranscription( + audioSamples: audioSamples, + languageSelection: languageSelection, + task: task, + primaryEngineId: primaryEngineId, + primaryCloudModelOverride: primaryCloudModelOverride, + prompt: prompt, + dictionaryTermHints: dictionaryTermHints, + normalizeNumbers: normalizeNumbers, + configuration: configuration, + threshold: hedgeThreshold + ) + } + let result = try await primaryTranscriptionRunner( + audioSamples, + languageSelection, + task, + primaryEngineId, + primaryCloudModelOverride, + prompt, + dictionaryTermHints, + normalizeNumbers ) return finalTranscriptionOutput( result: result, @@ -2237,10 +2281,13 @@ final class DictationViewModel: ObservableObject { modelId: primaryCloudModelOverride, usedRecoveryFallback: false ) + } catch let failure as AutomaticRecoveryFallbackFailure { + // The hedge already ran the fallback; don't retry it below. + throw failure } catch { let primaryError = error guard shouldAttemptAutomaticRecoveryFallback(after: primaryError), - let configuration = recoveryFallbackConfigurationProvider(primaryEngineId, task) else { + let configuration = fallbackConfiguration else { throw primaryError } @@ -2281,6 +2328,151 @@ final class DictationViewModel: ObservableObject { } } + private enum HedgedTranscriptionEvent { + case primary(Result) + case fallback(Result) + case fallbackSkipped + } + + private enum HedgedTranscriptionOutcome { + case primaryWon(TranscriptionResult) + case fallbackWon(TranscriptionResult) + case primaryFailedBeforeHedge(Error) + case bothFailed(primary: Error, fallback: Error) + } + + /// Races the primary engine against the recovery fallback engine: the fallback + /// request is dispatched only after `threshold` elapses with the primary still + /// running, the first successful transcription wins, and the loser is cancelled. + /// A primary failure before the hedge fires is rethrown so the caller's + /// sequential error-path fallback applies unchanged. + private func hedgedTranscription( + audioSamples: [Float], + languageSelection: LanguageSelection, + task: TranscriptionTask, + primaryEngineId: String?, + primaryCloudModelOverride: String?, + prompt: String?, + dictionaryTermHints: [PluginDictionaryTermHint], + normalizeNumbers: Bool?, + configuration: DictationRecoveryFallbackConfiguration, + threshold: TimeInterval + ) async throws -> FinalTranscriptionOutput { + let fallbackPrompt = dictionaryService.getTermsForPrompt(providerId: configuration.engineId) + let fallbackDictionaryTermHints = dictionaryService.getTermHints(providerId: configuration.engineId) + + let primaryOperation: @MainActor () async throws -> TranscriptionResult = { [primaryTranscriptionRunner] in + try await primaryTranscriptionRunner( + audioSamples, + languageSelection, + task, + primaryEngineId, + primaryCloudModelOverride, + prompt, + dictionaryTermHints, + normalizeNumbers + ) + } + let fallbackOperation: @MainActor () async throws -> TranscriptionResult = { [recoveryFallbackRunner] in + try await recoveryFallbackRunner( + audioSamples, + languageSelection, + task, + configuration, + fallbackPrompt, + fallbackDictionaryTermHints, + normalizeNumbers + ) + } + + let fallbackEngineId = configuration.engineId + let outcome = await withTaskGroup(of: HedgedTranscriptionEvent.self) { group -> HedgedTranscriptionOutcome in + let start = ContinuousClock.now + group.addTask { + do { return .primary(.success(try await primaryOperation())) } catch { return .primary(.failure(error)) } + } + group.addTask { [logger] in + do { + try await Task.sleep(nanoseconds: UInt64(threshold * 1_000_000_000)) + } catch { + return .fallbackSkipped + } + logger.info( + "Primary transcription exceeded hedge threshold (\(threshold, format: .fixed(precision: 1))s); racing recovery fallback engine \(fallbackEngineId, privacy: .public)" + ) + do { return .fallback(.success(try await fallbackOperation())) } catch { return .fallback(.failure(error)) } + } + + var primaryError: Error? + var fallbackError: Error? + while let event = await group.next() { + switch event { + case .primary(.success(let result)): + group.cancelAll() + return .primaryWon(result) + case .fallback(.success(let result)): + group.cancelAll() + return .fallbackWon(result) + case .primary(.failure(let error)): + let hedgeDispatched = ContinuousClock.now - start >= .seconds(threshold) + guard hedgeDispatched, shouldAttemptAutomaticRecoveryFallback(after: error) else { + group.cancelAll() + return .primaryFailedBeforeHedge(error) + } + if let fallbackError { + return .bothFailed(primary: error, fallback: fallbackError) + } + primaryError = error + case .fallback(.failure(let error)): + if let primaryError { + return .bothFailed(primary: primaryError, fallback: error) + } + fallbackError = error + case .fallbackSkipped: + if let primaryError { + return .primaryFailedBeforeHedge(primaryError) + } + } + } + // Both children finished without a winner (primary failed while the + // hedge was pending and the fallback then errored or was skipped). + if let primaryError { + return .primaryFailedBeforeHedge(primaryError) + } + return .primaryFailedBeforeHedge(CancellationError()) + } + + switch outcome { + case .primaryWon(let result): + return finalTranscriptionOutput( + result: result, + engineId: primaryEngineId, + modelId: primaryCloudModelOverride, + usedRecoveryFallback: false + ) + case .fallbackWon(let result): + logger.info( + "Hedged recovery fallback won the race with engine \(configuration.engineId, privacy: .public)" + ) + return finalTranscriptionOutput( + result: result, + engineId: configuration.engineId, + modelId: configuration.modelId, + usedRecoveryFallback: true + ) + case .primaryFailedBeforeHedge(let error): + throw error + case .bothFailed(let primary, let fallback): + logger.error( + "Hedged transcription failed on both engines; primary: \(primary.localizedDescription, privacy: .public), fallback: \(fallback.localizedDescription, privacy: .public)" + ) + throw AutomaticRecoveryFallbackFailure( + primaryDescription: primary.localizedDescription, + fallbackDescription: fallback.localizedDescription + ) + } + } + private func finalTranscriptionOutput( result: TranscriptionResult, engineId: String?, @@ -3426,3 +3618,27 @@ func paddedSamplesForFinalTranscription(_ samples: [Float], rawDuration: TimeInt return paddedSamples } + +#if DEBUG +extension DictationViewModel { + func transcribeFinalAudioForTesting( + audioSamples: [Float] = [], + languageSelection: LanguageSelection = LanguageSelection(storedValue: nil, nilBehavior: .auto), + task: TranscriptionTask = .transcribe, + primaryEngineId: String? = nil, + primaryCloudModelOverride: String? = nil + ) async throws -> (text: String, usedRecoveryFallback: Bool) { + let output = try await transcribeFinalAudio( + audioSamples: audioSamples, + languageSelection: languageSelection, + task: task, + primaryEngineId: primaryEngineId, + primaryCloudModelOverride: primaryCloudModelOverride, + prompt: nil, + dictionaryTermHints: [], + normalizeNumbers: nil + ) + return (output.result.text, output.usedRecoveryFallback) + } +} +#endif diff --git a/TypeWhisper/Views/DictationRecoveryView.swift b/TypeWhisper/Views/DictationRecoveryView.swift index d1061a606..badc9c358 100644 --- a/TypeWhisper/Views/DictationRecoveryView.swift +++ b/TypeWhisper/Views/DictationRecoveryView.swift @@ -155,6 +155,39 @@ struct DictationRecoveryView: View { } .disabled(viewModel.isProcessing || !viewModel.canUseAutomaticFallback) + Toggle(isOn: $viewModel.hedgeEnabled) { + Label( + localizedAppText("Race this engine when the primary is slow", de: "Diese Engine starten, wenn die primäre Engine langsam ist"), + systemImage: "hare" + ) + } + .disabled(viewModel.isProcessing || !viewModel.canUseAutomaticFallback || !viewModel.automaticFallbackEnabled) + + if viewModel.hedgeEnabled { + HStack { + Text(localizedAppText("Start racing after", de: "Rennen starten nach")) + Spacer() + Stepper( + value: $viewModel.hedgeThresholdSeconds, + in: 1.0...15.0, + step: 0.5 + ) { + Text(localizedAppText( + String(format: "%.1f seconds", viewModel.hedgeThresholdSeconds), + de: String(format: "%.1f Sekunden", viewModel.hedgeThresholdSeconds) + )) + } + } + .disabled(viewModel.isProcessing || !viewModel.canUseAutomaticFallback || !viewModel.automaticFallbackEnabled) + + Text(localizedAppText( + "If the primary engine hasn't answered by then, the same audio is also sent to this engine; whichever finishes first is used and the other request is cancelled. A dispatched race costs one extra API call.", + de: "Antwortet die primäre Engine bis dahin nicht, wird dasselbe Audio zusätzlich an diese Engine gesendet; das schnellere Ergebnis wird verwendet, die andere Anfrage abgebrochen. Ein ausgelöstes Rennen kostet einen zusätzlichen API-Aufruf." + )) + .font(.caption) + .foregroundStyle(.secondary) + } + if let message = viewModel.automaticFallbackUnavailableMessage { Text(message) .font(.caption) diff --git a/TypeWhisperTests/TypeWhisperIntegrationTests.swift b/TypeWhisperTests/TypeWhisperIntegrationTests.swift index a23ed660f..bbc62535c 100644 --- a/TypeWhisperTests/TypeWhisperIntegrationTests.swift +++ b/TypeWhisperTests/TypeWhisperIntegrationTests.swift @@ -11815,6 +11815,165 @@ final class TypeWhisperIntegrationTests: XCTestCase { } } +// MARK: - Hedged transcription + +extension TypeWhisperIntegrationTests { + private static func hedgeTranscriptionResult(text: String, engine: String) -> TranscriptionResult { + TranscriptionResult( + text: text, + detectedLanguage: "en", + duration: 1, + processingTime: 0.1, + engineUsed: engine, + segments: [] + ) + } + + @MainActor + private func makeHedgedDictationViewModel( + hedgeThreshold: TimeInterval?, + primaryRunner: @escaping DictationViewModel.PrimaryTranscriptionRunner, + fallbackRunner: @escaping DictationViewModel.RecoveryFallbackRunner + ) throws -> (viewModel: DictationViewModel, cleanup: () -> Void) { + let appSupportDirectory = try TestSupport.makeTemporaryDirectory() + + EventBus.shared = EventBus() + PluginManager.shared = PluginManager(appSupportDirectory: appSupportDirectory) + + let modelManager = ModelManagerService() + let audioRecordingService = AudioRecordingService() + let hotkeyService = HotkeyService() + let textInsertionService = TextInsertionService() + let historyService = HistoryService(appSupportDirectory: appSupportDirectory) + let recentTranscriptionStore = RecentTranscriptionStore() + let profileService = ProfileService(appSupportDirectory: appSupportDirectory) + let workflowService = WorkflowService(appSupportDirectory: appSupportDirectory) + let audioDuckingService = AudioDuckingService() + let dictionaryService = DictionaryService(appSupportDirectory: appSupportDirectory) + let snippetService = SnippetService(appSupportDirectory: appSupportDirectory) + let soundService = SoundService() + let audioDeviceService = AudioDeviceService() + let promptActionService = PromptActionService(appSupportDirectory: appSupportDirectory) + let promptProcessingService = PromptProcessingService() + let appFormatterService = AppFormatterService() + let punctuationProfileStore = DictationPunctuationProfileStore( + defaults: UserDefaults(suiteName: UUID().uuidString)!, + storageKey: UUID().uuidString + ) + let punctuationRulesLoader = PunctuationRulesLoader() + let punctuationStrategyResolver = PunctuationStrategyResolver(profileStore: punctuationProfileStore) + let speechFeedbackService = SpeechFeedbackService() + let accessibilityAnnouncementService = AccessibilityAnnouncementService() + let errorLogService = ErrorLogService(appSupportDirectory: appSupportDirectory) + let settingsViewModel = SettingsViewModel(modelManager: modelManager) + + let viewModel = DictationViewModel( + audioRecordingService: audioRecordingService, + textInsertionService: textInsertionService, + hotkeyService: hotkeyService, + modelManager: modelManager, + settingsViewModel: settingsViewModel, + historyService: historyService, + recentTranscriptionStore: recentTranscriptionStore, + profileService: profileService, + workflowService: workflowService, + translationService: nil, + audioDuckingService: audioDuckingService, + dictionaryService: dictionaryService, + snippetService: snippetService, + soundService: soundService, + audioDeviceService: audioDeviceService, + promptActionService: promptActionService, + promptProcessingService: promptProcessingService, + appFormatterService: appFormatterService, + punctuationStrategyResolver: punctuationStrategyResolver, + speechPunctuationService: SpeechPunctuationService(rulesLoader: punctuationRulesLoader), + speechFeedbackService: speechFeedbackService, + accessibilityAnnouncementService: accessibilityAnnouncementService, + errorLogService: errorLogService, + mediaPlaybackService: MediaPlaybackService(startListening: false), + recoveryFallbackConfigurationProvider: { _, _ in + DictationRecoveryFallbackConfiguration(engineId: "test-fallback", modelId: "test-model") + }, + recoveryFallbackRunner: fallbackRunner, + recoveryHedgeThresholdProvider: { hedgeThreshold }, + primaryTranscriptionRunner: primaryRunner + ) + viewModel.soundFeedbackEnabled = false + return (viewModel, { TestSupport.remove(appSupportDirectory) }) + } + + @MainActor + func testHedgeDispatchesFallbackWhenPrimaryIsSlowAndFallbackWins() async throws { + var fallbackCalled = false + let harness = try makeHedgedDictationViewModel( + hedgeThreshold: 0.2, + primaryRunner: { _, _, _, _, _, _, _, _ in + try await Task.sleep(nanoseconds: 10_000_000_000) + return Self.hedgeTranscriptionResult(text: "primary", engine: "primary") + }, + fallbackRunner: { _, _, _, _, _, _, _ in + fallbackCalled = true + return Self.hedgeTranscriptionResult(text: "fallback", engine: "test-fallback") + } + ) + defer { harness.cleanup() } + + let start = ContinuousClock.now + let output = try await harness.viewModel.transcribeFinalAudioForTesting() + + XCTAssertTrue(fallbackCalled) + XCTAssertTrue(output.usedRecoveryFallback) + XCTAssertEqual(output.text, "fallback") + XCTAssertLessThan(ContinuousClock.now - start, .seconds(5)) + } + + @MainActor + func testHedgePrimaryWinsBeforeThresholdWithoutDispatchingFallback() async throws { + var fallbackCalled = false + let harness = try makeHedgedDictationViewModel( + hedgeThreshold: 1.5, + primaryRunner: { _, _, _, _, _, _, _, _ in + Self.hedgeTranscriptionResult(text: "primary", engine: "primary") + }, + fallbackRunner: { _, _, _, _, _, _, _ in + fallbackCalled = true + return Self.hedgeTranscriptionResult(text: "fallback", engine: "test-fallback") + } + ) + defer { harness.cleanup() } + + let output = try await harness.viewModel.transcribeFinalAudioForTesting() + + XCTAssertFalse(output.usedRecoveryFallback) + XCTAssertEqual(output.text, "primary") + try await Task.sleep(nanoseconds: 100_000_000) + XCTAssertFalse(fallbackCalled) + } + + @MainActor + func testHedgePrimaryFailureBeforeThresholdFallsBackWithoutWaiting() async throws { + let harness = try makeHedgedDictationViewModel( + hedgeThreshold: 8.0, + primaryRunner: { _, _, _, _, _, _, _, _ in + throw PluginTranscriptionError.rateLimited + }, + fallbackRunner: { _, _, _, _, _, _, _ in + Self.hedgeTranscriptionResult(text: "fallback", engine: "test-fallback") + } + ) + defer { harness.cleanup() } + + let start = ContinuousClock.now + let output = try await harness.viewModel.transcribeFinalAudioForTesting() + + XCTAssertTrue(output.usedRecoveryFallback) + XCTAssertEqual(output.text, "fallback") + // Must not wait out the 8s hedge threshold before falling back. + XCTAssertLessThan(ContinuousClock.now - start, .seconds(4)) + } +} + final class AudioRecordingServiceInputAvailabilityTests: XCTestCase { func testStartRecording_throwsNoMicrophoneDetectedBeforeStartingOverride() { let service = AudioRecordingService() From 7e5c192eadec9a56acccb1961fd5f2cd120adb70 Mon Sep 17 00:00:00 2001 From: Josh Date: Sat, 5 Sep 2026 20:29:09 -0400 Subject: [PATCH 2/9] Settle the hedged race on the first decisive event; validate the threshold; keep the fallback engine selection - The race no longer uses a task group, which waits for every child before its scope exits: with a losing engine that does not honour cooperative cancellation (the plugin contract does not guarantee it) the winner was delayed until the loser gave up, defeating the feature. Both requests now run as unstructured tasks under an arbiter that resumes the caller on the first decisive event, cancels both tasks, and drops whatever the loser reports later; the loser is never awaited. Outer cancellation is forwarded. Regression test uses a primary that ignores cancellation entirely and asserts the fallback result returns without waiting. - The hedge threshold is clamped to the range the UI offers (1...15 s) at every entry point - stored value, live value, and settings-backup restore, where a non-finite or out-of-range value is rejected - so a value like 1e308 can never reach the sleep conversion. - The threshold label uses locale-aware number formatting. - DictationRecoveryViewModel no longer erases the stored recovery engine when the plugin manager cannot resolve it at that instant (plugin bundles still loading after a relaunch, or a reload in flight). That reconcile runs on every plugin-manager change and was silently wiping the fallback engine, which turns the hedge off entirely; an unresolved selection already degrades safely to no fallback until the plugin is available. Co-Authored-By: Claude Fable 5.1 --- .../Services/SettingsBackupExporter.swift | 11 +- .../DictationRecoveryViewModel.swift | 35 ++-- .../ViewModels/DictationViewModel.swift | 159 ++++++++++++------ TypeWhisper/Views/DictationRecoveryView.swift | 8 +- .../FileTranscriptionViewModelTests.swift | 55 ++++++ .../SettingsBackupExporterTests.swift | 59 +++++++ .../TypeWhisperIntegrationTests.swift | 31 ++++ 7 files changed, 297 insertions(+), 61 deletions(-) diff --git a/TypeWhisper/Services/SettingsBackupExporter.swift b/TypeWhisper/Services/SettingsBackupExporter.swift index fa5268a72..66db1c096 100644 --- a/TypeWhisper/Services/SettingsBackupExporter.swift +++ b/TypeWhisper/Services/SettingsBackupExporter.swift @@ -864,7 +864,16 @@ enum SettingsBackupExporter { apply(preferences.dictationRecoveryLanguage, forKey: UserDefaultsKeys.dictationRecoveryLanguage) apply(preferences.dictationRecoveryAutomaticFallbackEnabled, forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled) apply(preferences.dictationRecoveryHedgeEnabled, forKey: UserDefaultsKeys.dictationRecoveryHedgeEnabled) - apply(preferences.dictationRecoveryHedgeThresholdSeconds, forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) + // A backup is user-editable JSON: only a finite value inside the range + // the UI offers is restored, anything else keeps the current setting. + apply( + preferences.dictationRecoveryHedgeThresholdSeconds.flatMap { value -> Double? in + guard value.isFinite, + DictationRecoveryViewModel.hedgeThresholdRange.contains(value) else { return nil } + return value + }, + forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds + ) apply(preferences.dictationRecoveryRetentionDays, forKey: UserDefaultsKeys.dictationRecoveryRetentionDays) if preferences.dictationRecoveryRetentionDays != nil { recoveryRetentionPolicyDidChange?(DictationRecoveryRetentionPolicy.load(from: userDefaults)) diff --git a/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift b/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift index 8d26e0b78..be3561c09 100644 --- a/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift +++ b/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift @@ -91,9 +91,20 @@ final class DictationRecoveryViewModel: ObservableObject { /// recovery fallback engine, or nil when hedging is off. The engine/licensing /// gates live in `automaticFallbackConfiguration` — hedging only activates when /// that returns a configuration. + /// Bounds of the hedge threshold the settings UI offers; stored values + /// (including ones restored from a settings backup) are clamped into it so + /// an out-of-range or non-finite value can never reach the race timer. + static let hedgeThresholdRange: ClosedRange = 1.0...15.0 + static let defaultHedgeThresholdSeconds: TimeInterval = 3.0 + + static func clampedHedgeThreshold(_ value: Double?) -> TimeInterval { + guard let value, value.isFinite else { return defaultHedgeThresholdSeconds } + return min(max(value, hedgeThresholdRange.lowerBound), hedgeThresholdRange.upperBound) + } + var automaticHedgeThreshold: TimeInterval? { - guard hedgeEnabled, automaticFallbackEnabled, hedgeThresholdSeconds > 0 else { return nil } - return hedgeThresholdSeconds + guard hedgeEnabled, automaticFallbackEnabled else { return nil } + return Self.clampedHedgeThreshold(hedgeThresholdSeconds) } @Published var retentionPolicy: DictationRecoveryRetentionPolicy { didSet { @@ -158,8 +169,9 @@ final class DictationRecoveryViewModel: ObservableObject { self.selectedModel = defaults.string(forKey: UserDefaultsKeys.dictationRecoveryModel) self.automaticFallbackEnabled = defaults.bool(forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled) self.hedgeEnabled = defaults.bool(forKey: UserDefaultsKeys.dictationRecoveryHedgeEnabled) - let storedHedgeThreshold = defaults.double(forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) - self.hedgeThresholdSeconds = storedHedgeThreshold > 0 ? storedHedgeThreshold : 3.0 + self.hedgeThresholdSeconds = Self.clampedHedgeThreshold( + defaults.object(forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) as? Double + ) self.retentionPolicy = retentionPolicy self.isInitialized = true @@ -426,12 +438,15 @@ final class DictationRecoveryViewModel: ObservableObject { } private func reconcileSelectionWithAvailablePlugins() { - guard let pluginManager = PluginManager.shared else { return } - if let selectedEngine, - pluginManager.transcriptionEngine(for: selectedEngine) == nil { - self.selectedEngine = nil - selectedModel = nil - } + // Deliberately keeps the stored engine selection even when the plugin + // manager cannot resolve it right now. This runs on every plugin-manager + // change, including the transient states while plugin bundles are still + // loading at launch or being reloaded, and clearing the selection there + // silently erased the user's fallback engine for good (observed in the + // field: the recovery engine keys vanished after a relaunch and the + // hedge race stopped dispatching). An unresolved selection already + // degrades safely: `resolvedEngine` is nil, so the automatic fallback + // configuration is withheld until the plugin is available again. normalizeLanguageSelectionForResolvedEngine() } diff --git a/TypeWhisper/ViewModels/DictationViewModel.swift b/TypeWhisper/ViewModels/DictationViewModel.swift index 4a71c65c3..a03577123 100644 --- a/TypeWhisper/ViewModels/DictationViewModel.swift +++ b/TypeWhisper/ViewModels/DictationViewModel.swift @@ -2341,6 +2341,77 @@ final class DictationViewModel: ObservableObject { case bothFailed(primary: Error, fallback: Error) } + /// Collects the outcome of a hedged race. Every transition happens on the + /// main actor; the first decisive event resumes the continuation and + /// cancels both tasks, everything that arrives afterwards is dropped. + @MainActor + private final class HedgedTranscriptionArbiter { + private var continuation: CheckedContinuation? + private var primaryTask: Task? + private var fallbackTask: Task? + private var primaryError: Error? + private var fallbackError: Error? + private var fallbackDispatched = false + private var settled = false + + func begin(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func register(primary: Task, fallback: Task) { + primaryTask = primary + fallbackTask = fallback + if settled { + primary.cancel() + fallback.cancel() + } + } + + /// Returns false when the race is already over, so a late timer never + /// dispatches a fallback request nobody is waiting for. + func markFallbackDispatched() -> Bool { + guard !settled else { return false } + fallbackDispatched = true + return true + } + + func primaryFailed(_ error: Error, eligibleForFallback: Bool) { + guard !settled else { return } + // Before the hedge fires (or for errors the sequential fallback must + // not retry) the caller's existing error path applies unchanged. + guard fallbackDispatched, eligibleForFallback else { + return settle(.primaryFailedBeforeHedge(error)) + } + if let fallbackError { + return settle(.bothFailed(primary: error, fallback: fallbackError)) + } + primaryError = error + } + + func fallbackFailed(_ error: Error) { + guard !settled else { return } + if let primaryError { + return settle(.bothFailed(primary: primaryError, fallback: error)) + } + fallbackError = error + } + + func fallbackSkipped() { + guard !settled, let primaryError else { return } + settle(.primaryFailedBeforeHedge(primaryError)) + } + + func settle(_ outcome: HedgedTranscriptionOutcome) { + guard !settled else { return } + settled = true + primaryTask?.cancel() + fallbackTask?.cancel() + let continuation = self.continuation + self.continuation = nil + continuation?.resume(returning: outcome) + } + } + /// Races the primary engine against the recovery fallback engine: the fallback /// request is dispatched only after `threshold` elapses with the primary still /// running, the first successful transcription wins, and the loser is cancelled. @@ -2386,60 +2457,52 @@ final class DictationViewModel: ObservableObject { } let fallbackEngineId = configuration.engineId - let outcome = await withTaskGroup(of: HedgedTranscriptionEvent.self) { group -> HedgedTranscriptionOutcome in - let start = ContinuousClock.now - group.addTask { - do { return .primary(.success(try await primaryOperation())) } catch { return .primary(.failure(error)) } - } - group.addTask { [logger] in - do { - try await Task.sleep(nanoseconds: UInt64(threshold * 1_000_000_000)) - } catch { - return .fallbackSkipped - } - logger.info( - "Primary transcription exceeded hedge threshold (\(threshold, format: .fixed(precision: 1))s); racing recovery fallback engine \(fallbackEngineId, privacy: .public)" - ) - do { return .fallback(.success(try await fallbackOperation())) } catch { return .fallback(.failure(error)) } - } - - var primaryError: Error? - var fallbackError: Error? - while let event = await group.next() { - switch event { - case .primary(.success(let result)): - group.cancelAll() - return .primaryWon(result) - case .fallback(.success(let result)): - group.cancelAll() - return .fallbackWon(result) - case .primary(.failure(let error)): - let hedgeDispatched = ContinuousClock.now - start >= .seconds(threshold) - guard hedgeDispatched, shouldAttemptAutomaticRecoveryFallback(after: error) else { - group.cancelAll() - return .primaryFailedBeforeHedge(error) - } - if let fallbackError { - return .bothFailed(primary: error, fallback: fallbackError) + // The race is settled by the first decisive event and returns at once. + // Both requests run as unstructured tasks so a losing engine that does + // not honour cooperative cancellation (the plugin contract does not + // guarantee prompt cancellation) cannot delay the winner: it is + // cancelled, its eventual result is dropped by the arbiter, and it is + // never awaited. A structured task group would wait for it. + let arbiter = HedgedTranscriptionArbiter() + let outcome = await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + arbiter.begin(continuation) + let primaryTask = Task { @MainActor [weak self] in + do { + let result = try await primaryOperation() + arbiter.settle(.primaryWon(result)) + } catch { + guard let self else { return arbiter.settle(.primaryFailedBeforeHedge(error)) } + arbiter.primaryFailed( + error, + eligibleForFallback: self.shouldAttemptAutomaticRecoveryFallback(after: error) + ) } - primaryError = error - case .fallback(.failure(let error)): - if let primaryError { - return .bothFailed(primary: primaryError, fallback: error) + } + let fallbackTask = Task { @MainActor [logger] in + do { + try await Task.sleep(nanoseconds: UInt64(threshold * 1_000_000_000)) + } catch { + arbiter.fallbackSkipped() + return } - fallbackError = error - case .fallbackSkipped: - if let primaryError { - return .primaryFailedBeforeHedge(primaryError) + guard arbiter.markFallbackDispatched() else { return } + logger.info( + "Primary transcription exceeded hedge threshold (\(threshold, format: .fixed(precision: 1))s); racing recovery fallback engine \(fallbackEngineId, privacy: .public)" + ) + do { + let result = try await fallbackOperation() + arbiter.settle(.fallbackWon(result)) + } catch { + arbiter.fallbackFailed(error) } } + arbiter.register(primary: primaryTask, fallback: fallbackTask) } - // Both children finished without a winner (primary failed while the - // hedge was pending and the fallback then errored or was skipped). - if let primaryError { - return .primaryFailedBeforeHedge(primaryError) + } onCancel: { + Task { @MainActor in + arbiter.settle(.primaryFailedBeforeHedge(CancellationError())) } - return .primaryFailedBeforeHedge(CancellationError()) } switch outcome { diff --git a/TypeWhisper/Views/DictationRecoveryView.swift b/TypeWhisper/Views/DictationRecoveryView.swift index badc9c358..33df5f354 100644 --- a/TypeWhisper/Views/DictationRecoveryView.swift +++ b/TypeWhisper/Views/DictationRecoveryView.swift @@ -173,8 +173,8 @@ struct DictationRecoveryView: View { step: 0.5 ) { Text(localizedAppText( - String(format: "%.1f seconds", viewModel.hedgeThresholdSeconds), - de: String(format: "%.1f Sekunden", viewModel.hedgeThresholdSeconds) + "\(hedgeThresholdLabelValue) seconds", + de: "\(hedgeThresholdLabelValue) Sekunden" )) } } @@ -249,6 +249,10 @@ struct DictationRecoveryView: View { } } + private var hedgeThresholdLabelValue: String { + viewModel.hedgeThresholdSeconds.formatted(.number.precision(.fractionLength(1))) + } + private var recoveryLanguageOptions: [(code: String, name: String)] { let supportedLanguages = viewModel.selectedEngineSupportedLanguages guard !supportedLanguages.isEmpty else { diff --git a/TypeWhisperTests/FileTranscriptionViewModelTests.swift b/TypeWhisperTests/FileTranscriptionViewModelTests.swift index 7d9487a67..65dde7426 100644 --- a/TypeWhisperTests/FileTranscriptionViewModelTests.swift +++ b/TypeWhisperTests/FileTranscriptionViewModelTests.swift @@ -827,6 +827,61 @@ final class FileTranscriptionViewModelTests: XCTestCase { XCTAssertNil(viewModel.files.first?.errorMessage) } + func testRecoveryEngineSelectionSurvivesPluginThatCannotBeResolvedYet() throws { + // The plugin manager reports no engine for this id (plugins still loading, + // or a reload in flight). The stored choice must not be erased for it. + let defaults = try makeDefaults() + defaults.set("engine-not-loaded-yet", forKey: UserDefaultsKeys.dictationRecoveryEngine) + defaults.set("some-model", forKey: UserDefaultsKeys.dictationRecoveryModel) + let store = DictationRecoveryAudioStore(directory: makeTemporaryDirectory()) + + let viewModel = DictationRecoveryViewModel( + audioRecordingService: AudioRecordingService(recoveryAudioStore: store), + modelManager: ModelManagerService(), + historyService: HistoryService(appSupportDirectory: makeTemporaryDirectory()), + audioFileService: AudioFileService(), + defaults: defaults + ) + + XCTAssertEqual(viewModel.selectedEngine, "engine-not-loaded-yet") + XCTAssertEqual(viewModel.selectedModel, "some-model") + XCTAssertEqual(defaults.string(forKey: UserDefaultsKeys.dictationRecoveryEngine), "engine-not-loaded-yet") + XCTAssertNil(viewModel.resolvedEngine, "an unresolved selection degrades to no engine rather than being erased") + XCTAssertNil(viewModel.automaticFallbackConfiguration(excluding: "groq", task: .transcribe)) + } + + func testHedgeThresholdIsClampedToTheSupportedRange() throws { + let defaults = try makeDefaults() + defaults.set(true, forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled) + defaults.set(true, forKey: UserDefaultsKeys.dictationRecoveryHedgeEnabled) + let store = DictationRecoveryAudioStore(directory: makeTemporaryDirectory()) + func makeViewModel() -> DictationRecoveryViewModel { + DictationRecoveryViewModel( + audioRecordingService: AudioRecordingService(recoveryAudioStore: store), + modelManager: ModelManagerService(), + historyService: HistoryService(appSupportDirectory: makeTemporaryDirectory()), + audioFileService: AudioFileService(), + defaults: defaults + ) + } + + defaults.set(1e308, forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) + XCTAssertEqual(makeViewModel().hedgeThresholdSeconds, 15.0) + XCTAssertEqual(makeViewModel().automaticHedgeThreshold, 15.0) + + defaults.set(Double.nan, forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) + XCTAssertEqual(makeViewModel().hedgeThresholdSeconds, 3.0) + + defaults.set(0.05, forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) + XCTAssertEqual(makeViewModel().hedgeThresholdSeconds, 1.0) + + defaults.removeObject(forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) + XCTAssertEqual(makeViewModel().hedgeThresholdSeconds, 3.0) + + XCTAssertEqual(DictationRecoveryViewModel.clampedHedgeThreshold(-Double.infinity), 3.0) + XCTAssertEqual(DictationRecoveryViewModel.clampedHedgeThreshold(4.5), 4.5) + } + func testRecoveryTranscribeUsesRecoveryEngineAndModelOverrides() async throws { let defaults = try makeDefaults() defaults.set(true, forKey: UserDefaultsKeys.saveAudioWithHistory) diff --git a/TypeWhisperTests/SettingsBackupExporterTests.swift b/TypeWhisperTests/SettingsBackupExporterTests.swift index cffa3247f..d2af6c573 100644 --- a/TypeWhisperTests/SettingsBackupExporterTests.swift +++ b/TypeWhisperTests/SettingsBackupExporterTests.swift @@ -627,6 +627,65 @@ final class SettingsBackupExporterTests: XCTestCase { XCTAssertFalse(result.updateChannelApplied) } + func testImportRejectsOutOfRangeHedgeThreshold() async throws { + // A backup is user-editable JSON; a value the UI could never produce must + // not reach the hedge timer (1e308 seconds would overflow the sleep). + func makeBackup(threshold: Double?) -> SettingsBackupExporter.SettingsBackup { + var preferences = SettingsBackupExporter.PreferencesDTO.empty + preferences.dictationRecoveryHedgeThresholdSeconds = threshold + return SettingsBackupExporter.SettingsBackup( + schemaVersion: SettingsBackupExporter.schemaVersion, + exportedAt: Date(), + appVersion: "1.0", + workflows: [], dictionaryEntries: [], snippets: [], promptActions: [], profiles: [], + hotkeys: [:], plugins: [], + history: [], + updateChannel: nil, + preferences: preferences + ) + } + + let destination = try makeFixture() + defer { teardown(destination) } + destination.userDefaults.set(4.5, forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) + + for invalid in [1e308, -1, 0.0, 16, Double.infinity] { + _ = await SettingsBackupExporter.importBackup( + makeBackup(threshold: invalid), + workflowService: destination.workflowService, + dictionaryService: destination.dictionaryService, + snippetService: destination.snippetService, + profileService: destination.profileService, + promptActionService: destination.promptActionService, + pluginManager: destination.pluginManager, + pluginRegistryService: destination.pluginRegistryService, + historyService: destination.historyService, + usageStatisticsService: destination.usageStatisticsService, + userDefaults: destination.userDefaults + ) + XCTAssertEqual( + destination.userDefaults.double(forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds), + 4.5, + "threshold \(invalid) must be rejected" + ) + } + + _ = await SettingsBackupExporter.importBackup( + makeBackup(threshold: 7.5), + workflowService: destination.workflowService, + dictionaryService: destination.dictionaryService, + snippetService: destination.snippetService, + profileService: destination.profileService, + promptActionService: destination.promptActionService, + pluginManager: destination.pluginManager, + pluginRegistryService: destination.pluginRegistryService, + historyService: destination.historyService, + usageStatisticsService: destination.usageStatisticsService, + userDefaults: destination.userDefaults + ) + XCTAssertEqual(destination.userDefaults.double(forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds), 7.5) + } + func testUsageStatisticsNotRecordedWhenHistoryRecordSkipped() async throws { // rawText/finalText of only NUL characters sanitizes to an empty // string in HistoryService, so addRecord silently declines to insert diff --git a/TypeWhisperTests/TypeWhisperIntegrationTests.swift b/TypeWhisperTests/TypeWhisperIntegrationTests.swift index bbc62535c..93ab777a1 100644 --- a/TypeWhisperTests/TypeWhisperIntegrationTests.swift +++ b/TypeWhisperTests/TypeWhisperIntegrationTests.swift @@ -11928,6 +11928,37 @@ extension TypeWhisperIntegrationTests { XCTAssertLessThan(ContinuousClock.now - start, .seconds(5)) } + @MainActor + func testHedgeReturnsWinnerWhileNonCooperativeLoserKeepsRunning() async throws { + // The primary ignores cancellation entirely: it waits on a plain + // dispatch timer that no Task cancellation can interrupt. + var primaryFinished = false + let harness = try makeHedgedDictationViewModel( + hedgeThreshold: 0.1, + primaryRunner: { _, _, _, _, _, _, _, _ in + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.global().asyncAfter(deadline: .now() + 2.5) { + continuation.resume() + } + } + primaryFinished = true + return Self.hedgeTranscriptionResult(text: "primary", engine: "primary") + }, + fallbackRunner: { _, _, _, _, _, _, _ in + Self.hedgeTranscriptionResult(text: "fallback", engine: "test-fallback") + } + ) + defer { harness.cleanup() } + + let start = ContinuousClock.now + let output = try await harness.viewModel.transcribeFinalAudioForTesting() + + XCTAssertEqual(output.text, "fallback") + XCTAssertTrue(output.usedRecoveryFallback) + XCTAssertLessThan(ContinuousClock.now - start, .seconds(1.5), "the winner must be returned without waiting for the non-cooperative loser") + XCTAssertFalse(primaryFinished, "the loser was still running when the winner was returned") + } + @MainActor func testHedgePrimaryWinsBeforeThresholdWithoutDispatchingFallback() async throws { var fallbackCalled = false From 74c1ca95db77eca5fe12ca29608b9c756b65ae50 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 1 Sep 2026 15:18:51 -0400 Subject: [PATCH 3/9] Bound the final-transcription phase with a deadline A cloud transcription request that neither errors nor completes (a stalled upload, a server that accepted the audio and went silent) left the app in "Transcribing..." indefinitely; the only way out was Escape. The hedge race does not help here because both engines can hang the same way. transcribeFinalAudio now races the whole phase (primary, hedge, and the sequential fallback together) against a deadline of 60 seconds plus the recording's own length. When the deadline passes the in-flight requests are cancelled and a TranscriptionDeadlineExceeded error flows through the existing failure path, so the user sees an error and the recording is kept in Dictation Recovery instead of the app hanging. The bound is injectable for tests and scales with audio length so long recordings on slow local engines are not cut off. Co-Authored-By: Claude Fable 5.1 --- .../ViewModels/DictationViewModel.swift | 88 ++++++++++++++++++- .../TypeWhisperIntegrationTests.swift | 74 +++++++++++++++- 2 files changed, 159 insertions(+), 3 deletions(-) diff --git a/TypeWhisper/ViewModels/DictationViewModel.swift b/TypeWhisper/ViewModels/DictationViewModel.swift index a03577123..311944f2f 100644 --- a/TypeWhisper/ViewModels/DictationViewModel.swift +++ b/TypeWhisper/ViewModels/DictationViewModel.swift @@ -132,6 +132,10 @@ final class DictationViewModel: ObservableObject { _ normalizeNumbers: Bool? ) async throws -> TranscriptionResult typealias RecoveryHedgeThresholdProvider = @MainActor () -> TimeInterval? + /// Upper bound, in seconds, on the whole final-transcription phase (primary, + /// hedge, and sequential fallback together) for a recording of the given + /// duration. `nil` disables the bound. + typealias TranscriptionDeadlineProvider = @MainActor (_ audioDurationSeconds: TimeInterval) -> TimeInterval? typealias PrimaryTranscriptionRunner = @MainActor ( _ samples: [Float], _ languageSelection: LanguageSelection, @@ -143,7 +147,7 @@ final class DictationViewModel: ObservableObject { _ normalizeNumbers: Bool? ) async throws -> TranscriptionResult - private struct FinalTranscriptionOutput { + private struct FinalTranscriptionOutput: Sendable { let result: TranscriptionResult let modelId: String? let modelDisplayName: String? @@ -162,6 +166,25 @@ final class DictationViewModel: ObservableObject { } } + struct TranscriptionDeadlineExceeded: LocalizedError, Equatable { + let seconds: TimeInterval + + var errorDescription: String? { + let rounded = Int(seconds.rounded()) + return localizedAppText( + "Transcription timed out after \(rounded) seconds. The recording was kept in Dictation Recovery.", + de: "Die Transkription hat nach \(rounded) Sekunden das Zeitlimit überschritten. Die Aufnahme wurde in der Diktat-Wiederherstellung behalten." + ) + } + } + + /// Default final-transcription bound: a minute of headroom plus the + /// recording's own length, so long recordings on slow local engines are not + /// cut off while a hung cloud request can never pin the app in "Transcribing". + nonisolated static func defaultTranscriptionDeadline(forAudioDuration duration: TimeInterval) -> TimeInterval { + 60 + max(0, duration) + } + nonisolated(unsafe) static var _shared: DictationViewModel? static var shared: DictationViewModel { guard let instance = _shared else { @@ -333,6 +356,7 @@ final class DictationViewModel: ObservableObject { private let recoveryFallbackConfigurationProvider: RecoveryFallbackConfigurationProvider private let recoveryFallbackRunner: RecoveryFallbackRunner private let recoveryHedgeThresholdProvider: RecoveryHedgeThresholdProvider + private let transcriptionDeadlineProvider: TranscriptionDeadlineProvider private let primaryTranscriptionRunner: PrimaryTranscriptionRunner private var matchedWorkflow: Workflow? private var activeWorkflowMatch: WorkflowMatchResult? @@ -436,7 +460,8 @@ final class DictationViewModel: ObservableObject { recoveryFallbackConfigurationProvider: RecoveryFallbackConfigurationProvider? = nil, recoveryFallbackRunner: RecoveryFallbackRunner? = nil, recoveryHedgeThresholdProvider: RecoveryHedgeThresholdProvider? = nil, - primaryTranscriptionRunner: PrimaryTranscriptionRunner? = nil + primaryTranscriptionRunner: PrimaryTranscriptionRunner? = nil, + transcriptionDeadlineProvider: TranscriptionDeadlineProvider? = nil ) { self.audioRecordingService = audioRecordingService self.textInsertionService = textInsertionService @@ -475,6 +500,9 @@ final class DictationViewModel: ObservableObject { self.mediaPlaybackService = mediaPlaybackService self.recoveryFallbackConfigurationProvider = recoveryFallbackConfigurationProvider ?? { _, _ in nil } self.recoveryHedgeThresholdProvider = recoveryHedgeThresholdProvider ?? { nil } + self.transcriptionDeadlineProvider = transcriptionDeadlineProvider ?? { duration in + Self.defaultTranscriptionDeadline(forAudioDuration: duration) + } self.primaryTranscriptionRunner = primaryTranscriptionRunner ?? { [modelManager] samples, languageSelection, task, engineOverrideId, cloudModelOverride, prompt, dictionaryTermHints, normalizeNumbers in try await modelManager.transcribe( audioSamples: samples, @@ -2247,6 +2275,62 @@ final class DictationViewModel: ObservableObject { prompt: String?, dictionaryTermHints: [PluginDictionaryTermHint], normalizeNumbers: Bool? + ) async throws -> FinalTranscriptionOutput { + let audioDuration = Double(audioSamples.count) / AudioRecordingService.targetSampleRate + guard let deadline = transcriptionDeadlineProvider(audioDuration), deadline > 0 else { + return try await transcribeFinalAudioWithoutDeadline( + audioSamples: audioSamples, + languageSelection: languageSelection, + task: task, + primaryEngineId: primaryEngineId, + primaryCloudModelOverride: primaryCloudModelOverride, + prompt: prompt, + dictionaryTermHints: dictionaryTermHints, + normalizeNumbers: normalizeNumbers + ) + } + + // Race the whole transcription phase against a deadline. Whichever finishes + // first wins and the other is cancelled, so a request that neither errors + // nor completes (a stalled upload, a server that accepted the audio and + // went silent) cannot leave the app stuck in "Transcribing..." with no way + // out but Escape. + let transcriptionOperation: @MainActor () async throws -> FinalTranscriptionOutput = { [self] in + try await self.transcribeFinalAudioWithoutDeadline( + audioSamples: audioSamples, + languageSelection: languageSelection, + task: task, + primaryEngineId: primaryEngineId, + primaryCloudModelOverride: primaryCloudModelOverride, + prompt: prompt, + dictionaryTermHints: dictionaryTermHints, + normalizeNumbers: normalizeNumbers + ) + } + return try await withThrowingTaskGroup(of: FinalTranscriptionOutput.self) { group in + group.addTask { try await transcriptionOperation() } + group.addTask { [logger] in + try await Task.sleep(nanoseconds: UInt64(deadline * 1_000_000_000)) + logger.error("Final transcription exceeded its deadline of \(deadline, format: .fixed(precision: 1))s; abandoning in-flight requests") + throw TranscriptionDeadlineExceeded(seconds: deadline) + } + defer { group.cancelAll() } + guard let output = try await group.next() else { + throw TranscriptionDeadlineExceeded(seconds: deadline) + } + return output + } + } + + private func transcribeFinalAudioWithoutDeadline( + audioSamples: [Float], + languageSelection: LanguageSelection, + task: TranscriptionTask, + primaryEngineId: String?, + primaryCloudModelOverride: String?, + prompt: String?, + dictionaryTermHints: [PluginDictionaryTermHint], + normalizeNumbers: Bool? ) async throws -> FinalTranscriptionOutput { let fallbackConfiguration = recoveryFallbackConfigurationProvider(primaryEngineId, task) do { diff --git a/TypeWhisperTests/TypeWhisperIntegrationTests.swift b/TypeWhisperTests/TypeWhisperIntegrationTests.swift index 93ab777a1..bb1bf98d8 100644 --- a/TypeWhisperTests/TypeWhisperIntegrationTests.swift +++ b/TypeWhisperTests/TypeWhisperIntegrationTests.swift @@ -11832,6 +11832,7 @@ extension TypeWhisperIntegrationTests { @MainActor private func makeHedgedDictationViewModel( hedgeThreshold: TimeInterval?, + transcriptionDeadline: TimeInterval? = nil, primaryRunner: @escaping DictationViewModel.PrimaryTranscriptionRunner, fallbackRunner: @escaping DictationViewModel.RecoveryFallbackRunner ) throws -> (viewModel: DictationViewModel, cleanup: () -> Void) { @@ -11897,12 +11898,83 @@ extension TypeWhisperIntegrationTests { }, recoveryFallbackRunner: fallbackRunner, recoveryHedgeThresholdProvider: { hedgeThreshold }, - primaryTranscriptionRunner: primaryRunner + primaryTranscriptionRunner: primaryRunner, + transcriptionDeadlineProvider: transcriptionDeadline.map { deadline -> DictationViewModel.TranscriptionDeadlineProvider in + { _ in deadline } + } ) viewModel.soundFeedbackEnabled = false return (viewModel, { TestSupport.remove(appSupportDirectory) }) } + @MainActor + func testTranscriptionDeadlineAbandonsHungPrimaryAndFallback() async throws { + var primaryCancelled = false + var fallbackCancelled = false + let harness = try makeHedgedDictationViewModel( + hedgeThreshold: 0.1, + transcriptionDeadline: 0.5, + primaryRunner: { _, _, _, _, _, _, _, _ in + do { + try await Task.sleep(nanoseconds: 30_000_000_000) + } catch { + primaryCancelled = true + throw error + } + return Self.hedgeTranscriptionResult(text: "primary", engine: "primary") + }, + fallbackRunner: { _, _, _, _, _, _, _ in + do { + try await Task.sleep(nanoseconds: 30_000_000_000) + } catch { + fallbackCancelled = true + throw error + } + return Self.hedgeTranscriptionResult(text: "fallback", engine: "test-fallback") + } + ) + defer { harness.cleanup() } + + let start = ContinuousClock.now + do { + _ = try await harness.viewModel.transcribeFinalAudioForTesting() + XCTFail("A transcription that never completes must hit the deadline") + } catch let error as DictationViewModel.TranscriptionDeadlineExceeded { + XCTAssertEqual(error.seconds, 0.5) + } + XCTAssertLessThan(ContinuousClock.now - start, .seconds(5)) + + try await Task.sleep(nanoseconds: 200_000_000) + XCTAssertTrue(primaryCancelled, "the hung primary request must be cancelled once the deadline passes") + XCTAssertTrue(fallbackCancelled, "the hung fallback request must be cancelled once the deadline passes") + } + + @MainActor + func testTranscriptionDeadlineDoesNotInterfereWithFastPrimary() async throws { + let harness = try makeHedgedDictationViewModel( + hedgeThreshold: 1.0, + transcriptionDeadline: 5.0, + primaryRunner: { _, _, _, _, _, _, _, _ in + Self.hedgeTranscriptionResult(text: "primary", engine: "primary") + }, + fallbackRunner: { _, _, _, _, _, _, _ in + XCTFail("fallback must not run when the primary answers immediately") + return Self.hedgeTranscriptionResult(text: "fallback", engine: "test-fallback") + } + ) + defer { harness.cleanup() } + + let output = try await harness.viewModel.transcribeFinalAudioForTesting() + XCTAssertEqual(output.text, "primary") + XCTAssertFalse(output.usedRecoveryFallback) + } + + func testDefaultTranscriptionDeadlineScalesWithRecordingLength() { + XCTAssertEqual(DictationViewModel.defaultTranscriptionDeadline(forAudioDuration: 0), 60) + XCTAssertEqual(DictationViewModel.defaultTranscriptionDeadline(forAudioDuration: 90), 150) + XCTAssertEqual(DictationViewModel.defaultTranscriptionDeadline(forAudioDuration: -5), 60) + } + @MainActor func testHedgeDispatchesFallbackWhenPrimaryIsSlowAndFallbackWins() async throws { var fallbackCalled = false From 544fbfe969d0000417298c4cf09d4f3b69a12a8b Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 10 Sep 2026 17:39:25 -0400 Subject: [PATCH 4/9] Settle the transcription deadline on the first decisive event; reload imported recovery preferences Review follow-ups for the transcription deadline: - The deadline no longer depends on cooperative cancellation. The whole final-transcription phase runs as an unstructured task settled through a DeadlineArbiter (the same first-decisive-event design the hedge race uses): when the deadline fires the caller receives TranscriptionDeadlineExceeded at the bound, the in-flight work is cancelled (which aborts the transport for runners that honour cancellation) but never awaited, and a late result is dropped. Outer cancellation is forwarded. Regression test with runners that wait on plain dispatch timers no Task cancellation can interrupt. - Imported hedge settings now update the live configuration: SettingsBackupExporter.importBackup reports when any recovery preference was applied, and the settings view reloads DictationRecoveryViewModel.shared from UserDefaults, so the values ServiceContainer consults change without a restart. Tests cover the import notification and the reload (including clamping). Co-Authored-By: Claude Fable 5.1 --- .../Services/SettingsBackupExporter.swift | 9 +- .../DictationRecoveryViewModel.swift | 21 +++++ .../ViewModels/DictationViewModel.swift | 93 ++++++++++++++++--- TypeWhisper/Views/AdvancedSettingsView.swift | 3 + .../FileTranscriptionViewModelTests.swift | 37 ++++++++ .../SettingsBackupExporterTests.swift | 47 ++++++++++ .../TypeWhisperIntegrationTests.swift | 39 ++++++++ 7 files changed, 233 insertions(+), 16 deletions(-) diff --git a/TypeWhisper/Services/SettingsBackupExporter.swift b/TypeWhisper/Services/SettingsBackupExporter.swift index 66db1c096..eae8c1e02 100644 --- a/TypeWhisper/Services/SettingsBackupExporter.swift +++ b/TypeWhisper/Services/SettingsBackupExporter.swift @@ -634,7 +634,8 @@ enum SettingsBackupExporter { usageStatisticsService: UsageStatisticsService, userDefaults: UserDefaults = .standard, liveFieldTranscriptEnabledDidChange: ((Bool) -> Void)? = nil, - recoveryRetentionPolicyDidChange: ((DictationRecoveryRetentionPolicy) -> Void)? = nil + recoveryRetentionPolicyDidChange: ((DictationRecoveryRetentionPolicy) -> Void)? = nil, + dictationRecoveryPreferencesDidChange: (() -> Void)? = nil ) async -> ImportResult { var result = ImportResult() @@ -875,6 +876,12 @@ enum SettingsBackupExporter { forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds ) apply(preferences.dictationRecoveryRetentionDays, forKey: UserDefaultsKeys.dictationRecoveryRetentionDays) + if preferences.dictationRecoveryLanguage != nil + || preferences.dictationRecoveryAutomaticFallbackEnabled != nil + || preferences.dictationRecoveryHedgeEnabled != nil + || preferences.dictationRecoveryHedgeThresholdSeconds != nil { + dictationRecoveryPreferencesDidChange?() + } if preferences.dictationRecoveryRetentionDays != nil { recoveryRetentionPolicyDidChange?(DictationRecoveryRetentionPolicy.load(from: userDefaults)) } diff --git a/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift b/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift index be3561c09..2277eb058 100644 --- a/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift +++ b/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift @@ -268,6 +268,27 @@ final class DictationRecoveryViewModel: ObservableObject { ) } + /// Re-reads the recovery preferences after a settings-backup import wrote + /// them to UserDefaults. The view model is initialized once at launch and is + /// what ServiceContainer consults for the live fallback and hedge values, so + /// without this the imported values stayed invisible until a restart. + func reloadPreferencesFromDefaults() { + isInitialized = false + defer { isInitialized = true } + selectedEngine = defaults.string(forKey: UserDefaultsKeys.dictationRecoveryEngine) + selectedModel = defaults.string(forKey: UserDefaultsKeys.dictationRecoveryModel) + languageSelection = LanguageSelection( + storedValue: defaults.string(forKey: UserDefaultsKeys.dictationRecoveryLanguage), + nilBehavior: .auto + ) + automaticFallbackEnabled = defaults.bool(forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled) + hedgeEnabled = defaults.bool(forKey: UserDefaultsKeys.dictationRecoveryHedgeEnabled) + hedgeThresholdSeconds = Self.clampedHedgeThreshold( + defaults.object(forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) as? Double + ) + normalizeLanguageSelectionForResolvedEngine() + } + func observePluginManager() { guard let pluginManager = PluginManager.shared else { return } pluginManager.objectWillChange diff --git a/TypeWhisper/ViewModels/DictationViewModel.swift b/TypeWhisper/ViewModels/DictationViewModel.swift index 311944f2f..416d66114 100644 --- a/TypeWhisper/ViewModels/DictationViewModel.swift +++ b/TypeWhisper/ViewModels/DictationViewModel.swift @@ -2290,11 +2290,16 @@ final class DictationViewModel: ObservableObject { ) } - // Race the whole transcription phase against a deadline. Whichever finishes - // first wins and the other is cancelled, so a request that neither errors - // nor completes (a stalled upload, a server that accepted the audio and - // went silent) cannot leave the app stuck in "Transcribing..." with no way - // out but Escape. + // Bound the whole transcription phase (primary, hedge, and the sequential + // fallback) by a deadline that holds regardless of how the runners behave: + // the phase is an unstructured task settled through an arbiter, so when + // the deadline fires the caller gets TranscriptionDeadlineExceeded at the + // bound. The in-flight work is cancelled - which aborts the transport for + // runners that honour cancellation - but it is never awaited, so a runner + // that ignores cancellation (a stalled upload, a server that accepted the + // audio and went silent, a plugin without prompt cancellation) cannot + // hold the app in "Transcribing..." past the bound. Its late result is + // dropped by the arbiter. let transcriptionOperation: @MainActor () async throws -> FinalTranscriptionOutput = { [self] in try await self.transcribeFinalAudioWithoutDeadline( audioSamples: audioSamples, @@ -2307,18 +2312,76 @@ final class DictationViewModel: ObservableObject { normalizeNumbers: normalizeNumbers ) } - return try await withThrowingTaskGroup(of: FinalTranscriptionOutput.self) { group in - group.addTask { try await transcriptionOperation() } - group.addTask { [logger] in - try await Task.sleep(nanoseconds: UInt64(deadline * 1_000_000_000)) - logger.error("Final transcription exceeded its deadline of \(deadline, format: .fixed(precision: 1))s; abandoning in-flight requests") - throw TranscriptionDeadlineExceeded(seconds: deadline) + let arbiter = DeadlineArbiter() + // The continuation only signals completion; the (non-Sendable) outcome + // stays inside the main-actor arbiter and is read back here. + await withTaskCancellationHandler { + await withCheckedContinuation { (continuation: CheckedContinuation) in + arbiter.begin(continuation) + let work = Task { @MainActor in + do { + arbiter.settle(.success(try await transcriptionOperation())) + } catch { + arbiter.settle(.failure(error)) + } + } + let timer = Task { @MainActor [logger] in + do { + try await Task.sleep(nanoseconds: UInt64(deadline * 1_000_000_000)) + } catch { + return + } + logger.error("Final transcription exceeded its deadline of \(deadline, format: .fixed(precision: 1))s; abandoning in-flight requests") + arbiter.settle(.failure(TranscriptionDeadlineExceeded(seconds: deadline))) + } + arbiter.register(work: work, timer: timer) } - defer { group.cancelAll() } - guard let output = try await group.next() else { - throw TranscriptionDeadlineExceeded(seconds: deadline) + } onCancel: { + Task { @MainActor in + arbiter.settle(.failure(CancellationError())) } - return output + } + return try arbiter.takeOutcome().get() + } + + /// Settles a deadline-bounded operation on its first outcome: the work's own + /// result, the deadline, or outer cancellation. Everything runs on the main + /// actor; the first call resumes the caller and cancels both tasks, later + /// calls are dropped, and neither task is ever awaited. + @MainActor + private final class DeadlineArbiter { + private var continuation: CheckedContinuation? + private var work: Task? + private var timer: Task? + private var settled = false + private var outcome: Result? + + func begin(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func takeOutcome() -> Result { + outcome ?? .failure(CancellationError()) + } + + func register(work: Task, timer: Task) { + self.work = work + self.timer = timer + if settled { + work.cancel() + timer.cancel() + } + } + + func settle(_ outcome: Result) { + guard !settled else { return } + settled = true + self.outcome = outcome + work?.cancel() + timer?.cancel() + let continuation = self.continuation + self.continuation = nil + continuation?.resume() } } diff --git a/TypeWhisper/Views/AdvancedSettingsView.swift b/TypeWhisper/Views/AdvancedSettingsView.swift index 73a4161aa..b279eb5d2 100644 --- a/TypeWhisper/Views/AdvancedSettingsView.swift +++ b/TypeWhisper/Views/AdvancedSettingsView.swift @@ -636,6 +636,9 @@ struct AdvancedSettingsView: View { }, recoveryRetentionPolicyDidChange: { policy in _ = container.audioRecordingService.updateRecoveryRetentionPolicy(policy) + }, + dictationRecoveryPreferencesDidChange: { + DictationRecoveryViewModel.shared.reloadPreferencesFromDefaults() } ) diff --git a/TypeWhisperTests/FileTranscriptionViewModelTests.swift b/TypeWhisperTests/FileTranscriptionViewModelTests.swift index 65dde7426..278c9e405 100644 --- a/TypeWhisperTests/FileTranscriptionViewModelTests.swift +++ b/TypeWhisperTests/FileTranscriptionViewModelTests.swift @@ -850,6 +850,43 @@ final class FileTranscriptionViewModelTests: XCTestCase { XCTAssertNil(viewModel.automaticFallbackConfiguration(excluding: "groq", task: .transcribe)) } + func testReloadPreferencesFromDefaultsPicksUpImportedValues() throws { + let defaults = try makeDefaults() + let store = DictationRecoveryAudioStore(directory: makeTemporaryDirectory()) + let viewModel = DictationRecoveryViewModel( + audioRecordingService: AudioRecordingService(recoveryAudioStore: store), + modelManager: ModelManagerService(), + historyService: HistoryService(appSupportDirectory: makeTemporaryDirectory()), + audioFileService: AudioFileService(), + defaults: defaults + ) + XCTAssertFalse(viewModel.hedgeEnabled) + XCTAssertEqual(viewModel.hedgeThresholdSeconds, 3.0) + + // A settings-backup import writes straight to UserDefaults behind the + // already-initialized view model. + defaults.set("imported-engine", forKey: UserDefaultsKeys.dictationRecoveryEngine) + defaults.set("imported-model", forKey: UserDefaultsKeys.dictationRecoveryModel) + defaults.set(true, forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled) + defaults.set(true, forKey: UserDefaultsKeys.dictationRecoveryHedgeEnabled) + defaults.set(7.5, forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) + defaults.set("de", forKey: UserDefaultsKeys.dictationRecoveryLanguage) + + viewModel.reloadPreferencesFromDefaults() + + XCTAssertEqual(viewModel.selectedEngine, "imported-engine") + XCTAssertEqual(viewModel.selectedModel, "imported-model", "reloading the engine must not reset the imported model") + XCTAssertTrue(viewModel.automaticFallbackEnabled) + XCTAssertTrue(viewModel.hedgeEnabled) + XCTAssertEqual(viewModel.hedgeThresholdSeconds, 7.5) + XCTAssertEqual(viewModel.automaticHedgeThreshold, 7.5) + XCTAssertEqual(viewModel.languageSelection, LanguageSelection(storedValue: "de", nilBehavior: .auto)) + + defaults.set(1e308, forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) + viewModel.reloadPreferencesFromDefaults() + XCTAssertEqual(viewModel.hedgeThresholdSeconds, 15.0, "a reloaded value is clamped like a stored one") + } + func testHedgeThresholdIsClampedToTheSupportedRange() throws { let defaults = try makeDefaults() defaults.set(true, forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled) diff --git a/TypeWhisperTests/SettingsBackupExporterTests.swift b/TypeWhisperTests/SettingsBackupExporterTests.swift index d2af6c573..77c1a46fc 100644 --- a/TypeWhisperTests/SettingsBackupExporterTests.swift +++ b/TypeWhisperTests/SettingsBackupExporterTests.swift @@ -627,6 +627,53 @@ final class SettingsBackupExporterTests: XCTestCase { XCTAssertFalse(result.updateChannelApplied) } + func testImportNotifiesWhenRecoveryPreferencesWereApplied() async throws { + func makeBackup(_ configure: (inout SettingsBackupExporter.PreferencesDTO) -> Void) -> SettingsBackupExporter.SettingsBackup { + var preferences = SettingsBackupExporter.PreferencesDTO.empty + configure(&preferences) + return SettingsBackupExporter.SettingsBackup( + schemaVersion: SettingsBackupExporter.schemaVersion, + exportedAt: Date(), + appVersion: "1.0", + workflows: [], dictionaryEntries: [], snippets: [], promptActions: [], profiles: [], + hotkeys: [:], plugins: [], + history: [], + updateChannel: nil, + preferences: preferences + ) + } + let destination = try makeFixture() + defer { teardown(destination) } + + var notifications = 0 + func importing(_ backup: SettingsBackupExporter.SettingsBackup) async { + _ = await SettingsBackupExporter.importBackup( + backup, + workflowService: destination.workflowService, + dictionaryService: destination.dictionaryService, + snippetService: destination.snippetService, + profileService: destination.profileService, + promptActionService: destination.promptActionService, + pluginManager: destination.pluginManager, + pluginRegistryService: destination.pluginRegistryService, + historyService: destination.historyService, + usageStatisticsService: destination.usageStatisticsService, + userDefaults: destination.userDefaults, + dictationRecoveryPreferencesDidChange: { notifications += 1 } + ) + } + + await importing(makeBackup { _ in }) + XCTAssertEqual(notifications, 0, "no recovery preference in the backup, nothing to reload") + + await importing(makeBackup { $0.dictationRecoveryHedgeThresholdSeconds = 7.5 }) + XCTAssertEqual(notifications, 1) + XCTAssertEqual(destination.userDefaults.double(forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds), 7.5) + + await importing(makeBackup { $0.dictationRecoveryHedgeEnabled = true }) + XCTAssertEqual(notifications, 2) + } + func testImportRejectsOutOfRangeHedgeThreshold() async throws { // A backup is user-editable JSON; a value the UI could never produce must // not reach the hedge timer (1e308 seconds would overflow the sleep). diff --git a/TypeWhisperTests/TypeWhisperIntegrationTests.swift b/TypeWhisperTests/TypeWhisperIntegrationTests.swift index bb1bf98d8..55db492c9 100644 --- a/TypeWhisperTests/TypeWhisperIntegrationTests.swift +++ b/TypeWhisperTests/TypeWhisperIntegrationTests.swift @@ -11949,6 +11949,45 @@ extension TypeWhisperIntegrationTests { XCTAssertTrue(fallbackCancelled, "the hung fallback request must be cancelled once the deadline passes") } + @MainActor + func testTranscriptionDeadlineHoldsAgainstRunnersThatIgnoreCancellation() async throws { + // Both runners wait on plain dispatch timers that no Task cancellation can + // interrupt, i.e. engines whose transport never aborts. The deadline must + // still return at the bound instead of waiting for them. + var primaryFinished = false + var fallbackFinished = false + let harness = try makeHedgedDictationViewModel( + hedgeThreshold: 0.1, + transcriptionDeadline: 0.5, + primaryRunner: { _, _, _, _, _, _, _, _ in + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.global().asyncAfter(deadline: .now() + 3.0) { continuation.resume() } + } + primaryFinished = true + return Self.hedgeTranscriptionResult(text: "primary", engine: "primary") + }, + fallbackRunner: { _, _, _, _, _, _, _ in + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.global().asyncAfter(deadline: .now() + 3.0) { continuation.resume() } + } + fallbackFinished = true + return Self.hedgeTranscriptionResult(text: "fallback", engine: "test-fallback") + } + ) + defer { harness.cleanup() } + + let start = ContinuousClock.now + do { + _ = try await harness.viewModel.transcribeFinalAudioForTesting() + XCTFail("The deadline must fire while both runners are still hung") + } catch let error as DictationViewModel.TranscriptionDeadlineExceeded { + XCTAssertEqual(error.seconds, 0.5) + } + XCTAssertLessThan(ContinuousClock.now - start, .seconds(1.5), "the bound must not wait for runners that ignore cancellation") + XCTAssertFalse(primaryFinished, "the primary was still hung when the deadline returned") + XCTAssertFalse(fallbackFinished, "the fallback was still hung when the deadline returned") + } + @MainActor func testTranscriptionDeadlineDoesNotInterfereWithFastPrimary() async throws { let harness = try makeHedgedDictationViewModel( From 35c1edbd1bdc0a14b4400d2cad314721392ebdf7 Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 10 Sep 2026 18:01:36 -0400 Subject: [PATCH 5/9] Reload the retention policy into the recovery view model after a settings import A retention-only import updated AudioRecordingService through recoveryRetentionPolicyDidChange but left DictationRecoveryViewModel's retentionPolicy stale. The import now also reports a retention change to dictationRecoveryPreferencesDidChange, and reloadPreferencesFromDefaults() loads the retention policy alongside the other recovery preferences. Co-Authored-By: Claude Fable 5.1 --- TypeWhisper/Services/SettingsBackupExporter.swift | 3 ++- TypeWhisper/ViewModels/DictationRecoveryViewModel.swift | 1 + TypeWhisperTests/FileTranscriptionViewModelTests.swift | 6 ++++++ TypeWhisperTests/SettingsBackupExporterTests.swift | 3 +++ 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/TypeWhisper/Services/SettingsBackupExporter.swift b/TypeWhisper/Services/SettingsBackupExporter.swift index e7e1b5ac5..2a2790765 100644 --- a/TypeWhisper/Services/SettingsBackupExporter.swift +++ b/TypeWhisper/Services/SettingsBackupExporter.swift @@ -884,7 +884,8 @@ enum SettingsBackupExporter { if preferences.dictationRecoveryLanguage != nil || preferences.dictationRecoveryAutomaticFallbackEnabled != nil || preferences.dictationRecoveryHedgeEnabled != nil - || preferences.dictationRecoveryHedgeThresholdSeconds != nil { + || preferences.dictationRecoveryHedgeThresholdSeconds != nil + || preferences.dictationRecoveryRetentionDays != nil { dictationRecoveryPreferencesDidChange?() } if preferences.dictationRecoveryRetentionDays != nil { diff --git a/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift b/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift index 2277eb058..9ccb26649 100644 --- a/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift +++ b/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift @@ -286,6 +286,7 @@ final class DictationRecoveryViewModel: ObservableObject { hedgeThresholdSeconds = Self.clampedHedgeThreshold( defaults.object(forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) as? Double ) + retentionPolicy = DictationRecoveryRetentionPolicy.load(from: defaults) normalizeLanguageSelectionForResolvedEngine() } diff --git a/TypeWhisperTests/FileTranscriptionViewModelTests.swift b/TypeWhisperTests/FileTranscriptionViewModelTests.swift index c79202eca..cca51c6e4 100644 --- a/TypeWhisperTests/FileTranscriptionViewModelTests.swift +++ b/TypeWhisperTests/FileTranscriptionViewModelTests.swift @@ -885,6 +885,12 @@ final class FileTranscriptionViewModelTests: XCTestCase { defaults.set(1e308, forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds) viewModel.reloadPreferencesFromDefaults() XCTAssertEqual(viewModel.hedgeThresholdSeconds, 15.0, "a reloaded value is clamped like a stored one") + + let retentionBefore = viewModel.retentionPolicy + defaults.set(180, forKey: UserDefaultsKeys.dictationRecoveryRetentionDays) + viewModel.reloadPreferencesFromDefaults() + XCTAssertEqual(viewModel.retentionPolicy, DictationRecoveryRetentionPolicy.load(from: defaults)) + XCTAssertNotEqual(viewModel.retentionPolicy, retentionBefore, "a retention-only import must reach the view model") } func testHedgeThresholdIsClampedToTheSupportedRange() throws { diff --git a/TypeWhisperTests/SettingsBackupExporterTests.swift b/TypeWhisperTests/SettingsBackupExporterTests.swift index 50a1d69b9..44511e3ad 100644 --- a/TypeWhisperTests/SettingsBackupExporterTests.swift +++ b/TypeWhisperTests/SettingsBackupExporterTests.swift @@ -732,6 +732,9 @@ final class SettingsBackupExporterTests: XCTestCase { await importing(makeBackup { $0.dictationRecoveryHedgeEnabled = true }) XCTAssertEqual(notifications, 2) + + await importing(makeBackup { $0.dictationRecoveryRetentionDays = 7 }) + XCTAssertEqual(notifications, 3, "a retention-only import must also reload the view model") } func testImportRejectsOutOfRangeHedgeThreshold() async throws { From 4a3dbc6574ad2f1c4d640d53a7e102417001329d Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 10 Sep 2026 18:19:46 -0400 Subject: [PATCH 6/9] Skip the hedge for a non-finite or non-positive threshold The threshold provider is injectable, so the conversion to a sleep duration in hedgedTranscription must not trust it: only a finite, positive value starts the race, anything else runs the primary path (with the sequential error-path fallback unchanged) instead of trapping in the UInt64 conversion. Regression test covers infinity, NaN, negative and zero. Co-Authored-By: Claude Fable 5.1 --- .../ViewModels/DictationViewModel.swift | 6 ++++- .../TypeWhisperIntegrationTests.swift | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/TypeWhisper/ViewModels/DictationViewModel.swift b/TypeWhisper/ViewModels/DictationViewModel.swift index 587a71fc4..7585529b3 100644 --- a/TypeWhisper/ViewModels/DictationViewModel.swift +++ b/TypeWhisper/ViewModels/DictationViewModel.swift @@ -2389,8 +2389,12 @@ final class DictationViewModel: ObservableObject { ) async throws -> FinalTranscriptionOutput { let fallbackConfiguration = recoveryFallbackConfigurationProvider(primaryEngineId, task) do { + // The provider is injectable; only a finite, positive threshold can be + // converted to a sleep duration, anything else means no hedge (the + // sequential error-path fallback below still applies). if let configuration = fallbackConfiguration, - let hedgeThreshold = recoveryHedgeThresholdProvider() { + let hedgeThreshold = recoveryHedgeThresholdProvider(), + hedgeThreshold.isFinite, hedgeThreshold > 0 { return try await hedgedTranscription( audioSamples: audioSamples, languageSelection: languageSelection, diff --git a/TypeWhisperTests/TypeWhisperIntegrationTests.swift b/TypeWhisperTests/TypeWhisperIntegrationTests.swift index 857194a4e..f57798ca0 100644 --- a/TypeWhisperTests/TypeWhisperIntegrationTests.swift +++ b/TypeWhisperTests/TypeWhisperIntegrationTests.swift @@ -13267,6 +13267,29 @@ extension TypeWhisperIntegrationTests { XCTAssertFalse(primaryFinished, "the loser was still running when the winner was returned") } + @MainActor + func testInvalidHedgeThresholdSkipsTheHedgeInsteadOfTrapping() async throws { + for invalid in [Double.infinity, Double.nan, -1.0, 0.0] { + var fallbackCalled = false + let harness = try makeHedgedDictationViewModel( + hedgeThreshold: invalid, + primaryRunner: { _, _, _, _, _, _, _, _ in + Self.hedgeTranscriptionResult(text: "primary", engine: "primary") + }, + fallbackRunner: { _, _, _, _, _, _, _ in + fallbackCalled = true + return Self.hedgeTranscriptionResult(text: "fallback", engine: "test-fallback") + } + ) + defer { harness.cleanup() } + + let output = try await harness.viewModel.transcribeFinalAudioForTesting() + XCTAssertEqual(output.text, "primary", "threshold \(invalid) must fall back to the primary path") + XCTAssertFalse(output.usedRecoveryFallback) + XCTAssertFalse(fallbackCalled) + } + } + @MainActor func testHedgePrimaryWinsBeforeThresholdWithoutDispatchingFallback() async throws { var fallbackCalled = false From 384d18a6c753580fb097f357b794aec6d476c86c Mon Sep 17 00:00:00 2001 From: Josh Date: Thu, 10 Sep 2026 18:19:49 -0400 Subject: [PATCH 7/9] Forward the live-reload callbacks through the automation import service SettingsBackupAutomationService (the local HTTP API's import path) passed neither cancellationBehaviorDidChange nor dictationRecoveryPreferencesDidChange to importBackup, so an in-process import updated UserDefaults while DictationViewModel and DictationRecoveryViewModel kept stale values. Both callbacks are now accepted, forwarded, and wired in ServiceContainer the same way the settings view wires them. Test covers the forwarding. Co-Authored-By: Claude Fable 5.1 --- TypeWhisper/App/ServiceContainer.swift | 6 +++ .../Services/SettingsBackupExporter.swift | 12 +++++- .../SettingsBackupExporterTests.swift | 39 +++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/TypeWhisper/App/ServiceContainer.swift b/TypeWhisper/App/ServiceContainer.swift index 183cb0e57..b08aeb313 100644 --- a/TypeWhisper/App/ServiceContainer.swift +++ b/TypeWhisper/App/ServiceContainer.swift @@ -261,6 +261,12 @@ final class ServiceContainer: ObservableObject { }, recoveryRetentionPolicyDidChange: { [audioRecordingService] policy in _ = audioRecordingService.updateRecoveryRetentionPolicy(policy) + }, + cancellationBehaviorDidChange: { [dictationViewModel] behavior in + dictationViewModel.cancellationBehavior = behavior + }, + dictationRecoveryPreferencesDidChange: { [recoveryViewModel] in + recoveryViewModel.reloadPreferencesFromDefaults() } ) let handlers = APIHandlers( diff --git a/TypeWhisper/Services/SettingsBackupExporter.swift b/TypeWhisper/Services/SettingsBackupExporter.swift index 2a2790765..289b9c012 100644 --- a/TypeWhisper/Services/SettingsBackupExporter.swift +++ b/TypeWhisper/Services/SettingsBackupExporter.swift @@ -949,6 +949,8 @@ final class SettingsBackupAutomationService { private let userDefaults: UserDefaults private let liveFieldTranscriptEnabledDidChange: ((Bool) -> Void)? private let recoveryRetentionPolicyDidChange: ((DictationRecoveryRetentionPolicy) -> Void)? + private let cancellationBehaviorDidChange: ((CancellationBehavior) -> Void)? + private let dictationRecoveryPreferencesDidChange: (() -> Void)? init( workflowService: WorkflowService, @@ -962,7 +964,9 @@ final class SettingsBackupAutomationService { usageStatisticsService: UsageStatisticsService, userDefaults: UserDefaults = .standard, liveFieldTranscriptEnabledDidChange: ((Bool) -> Void)? = nil, - recoveryRetentionPolicyDidChange: ((DictationRecoveryRetentionPolicy) -> Void)? = nil + recoveryRetentionPolicyDidChange: ((DictationRecoveryRetentionPolicy) -> Void)? = nil, + cancellationBehaviorDidChange: ((CancellationBehavior) -> Void)? = nil, + dictationRecoveryPreferencesDidChange: (() -> Void)? = nil ) { self.workflowService = workflowService self.dictionaryService = dictionaryService @@ -976,6 +980,8 @@ final class SettingsBackupAutomationService { self.userDefaults = userDefaults self.liveFieldTranscriptEnabledDidChange = liveFieldTranscriptEnabledDidChange self.recoveryRetentionPolicyDidChange = recoveryRetentionPolicyDidChange + self.cancellationBehaviorDidChange = cancellationBehaviorDidChange + self.dictationRecoveryPreferencesDidChange = dictationRecoveryPreferencesDidChange } func exportData() throws -> Data { @@ -1007,7 +1013,9 @@ final class SettingsBackupAutomationService { usageStatisticsService: usageStatisticsService, userDefaults: userDefaults, liveFieldTranscriptEnabledDidChange: liveFieldTranscriptEnabledDidChange, - recoveryRetentionPolicyDidChange: recoveryRetentionPolicyDidChange + cancellationBehaviorDidChange: cancellationBehaviorDidChange, + recoveryRetentionPolicyDidChange: recoveryRetentionPolicyDidChange, + dictationRecoveryPreferencesDidChange: dictationRecoveryPreferencesDidChange ) } } diff --git a/TypeWhisperTests/SettingsBackupExporterTests.swift b/TypeWhisperTests/SettingsBackupExporterTests.swift index 44511e3ad..1a707bb2a 100644 --- a/TypeWhisperTests/SettingsBackupExporterTests.swift +++ b/TypeWhisperTests/SettingsBackupExporterTests.swift @@ -737,6 +737,45 @@ final class SettingsBackupExporterTests: XCTestCase { XCTAssertEqual(notifications, 3, "a retention-only import must also reload the view model") } + func testAutomationImportForwardsRecoveryPreferencesReload() async throws { + // The local HTTP API imports through SettingsBackupAutomationService; it + // must forward the live-reload callbacks like the settings view does. + var preferences = SettingsBackupExporter.PreferencesDTO.empty + preferences.dictationRecoveryHedgeThresholdSeconds = 6.5 + let backup = SettingsBackupExporter.SettingsBackup( + schemaVersion: SettingsBackupExporter.schemaVersion, + exportedAt: Date(), + appVersion: "1.0", + workflows: [], dictionaryEntries: [], snippets: [], promptActions: [], profiles: [], + hotkeys: [:], plugins: [], + history: [], + updateChannel: nil, + preferences: preferences + ) + let destination = try makeFixture() + defer { teardown(destination) } + + var reloads = 0 + let service = SettingsBackupAutomationService( + workflowService: destination.workflowService, + dictionaryService: destination.dictionaryService, + snippetService: destination.snippetService, + profileService: destination.profileService, + promptActionService: destination.promptActionService, + pluginManager: destination.pluginManager, + pluginRegistryService: destination.pluginRegistryService, + historyService: destination.historyService, + usageStatisticsService: destination.usageStatisticsService, + userDefaults: destination.userDefaults, + dictationRecoveryPreferencesDidChange: { reloads += 1 } + ) + + _ = try await service.importData(SettingsBackupExporter.encodedJSON(backup)) + + XCTAssertEqual(reloads, 1) + XCTAssertEqual(destination.userDefaults.double(forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds), 6.5) + } + func testImportRejectsOutOfRangeHedgeThreshold() async throws { // A backup is user-editable JSON; a value the UI could never produce must // not reach the hedge timer (1e308 seconds would overflow the sleep). From 4273fb32d2076c2399b3fb73a7230817451e92c5 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 11 Sep 2026 13:39:17 -0400 Subject: [PATCH 8/9] Convert the hedge threshold to nanoseconds only when the product is representable Checking the threshold for finiteness and sign was not enough: 1e308 passes both and UInt64(1e308 * 1_000_000_000) still traps because the product is infinite, and a finite product past UInt64's range traps as well. The conversion now goes through hedgeDelayNanoseconds(forThreshold:), which accepts only a finite, positive threshold up to 60 s whose nanosecond product is finite and representable; anything else means no hedge, with the sequential error-path fallback unchanged. The race consults the same helper before sleeping. Tests cover 1e308, 1e12 and the 60 s boundary alongside the earlier invalid values, plus unit coverage of the helper. Co-Authored-By: Claude Fable 5.1 --- .../ViewModels/DictationViewModel.swift | 31 ++++++++++++++++--- .../TypeWhisperIntegrationTests.swift | 17 +++++++++- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/TypeWhisper/ViewModels/DictationViewModel.swift b/TypeWhisper/ViewModels/DictationViewModel.swift index 7585529b3..566544799 100644 --- a/TypeWhisper/ViewModels/DictationViewModel.swift +++ b/TypeWhisper/ViewModels/DictationViewModel.swift @@ -2389,12 +2389,12 @@ final class DictationViewModel: ObservableObject { ) async throws -> FinalTranscriptionOutput { let fallbackConfiguration = recoveryFallbackConfigurationProvider(primaryEngineId, task) do { - // The provider is injectable; only a finite, positive threshold can be - // converted to a sleep duration, anything else means no hedge (the - // sequential error-path fallback below still applies). + // The provider is injectable; only a threshold that converts to a + // sleep duration safely starts a race, anything else means no hedge + // (the sequential error-path fallback below still applies). if let configuration = fallbackConfiguration, let hedgeThreshold = recoveryHedgeThresholdProvider(), - hedgeThreshold.isFinite, hedgeThreshold > 0 { + Self.hedgeDelayNanoseconds(forThreshold: hedgeThreshold) != nil { return try await hedgedTranscription( audioSamples: audioSamples, languageSelection: languageSelection, @@ -2555,6 +2555,22 @@ final class DictationViewModel: ObservableObject { } } + /// Longest hedge threshold the race accepts. The settings UI offers 1...15 s; + /// anything past a minute is not a hedge any more and is treated as "no + /// hedge" rather than being converted. + nonisolated static let maximumHedgeThreshold: TimeInterval = 60 + + /// Converts a hedge threshold to a sleep duration, or nil when the value + /// must not be converted: non-finite, non-positive, or so large that the + /// nanosecond product would overflow (`UInt64(1e308 * 1e9)` traps). Guarding + /// the value alone is not enough; the product is what gets converted. + nonisolated static func hedgeDelayNanoseconds(forThreshold threshold: TimeInterval) -> UInt64? { + guard threshold.isFinite, threshold > 0, threshold <= maximumHedgeThreshold else { return nil } + let nanoseconds = threshold * 1_000_000_000 + guard nanoseconds.isFinite, nanoseconds < Double(UInt64.max) else { return nil } + return UInt64(nanoseconds) + } + /// Races the primary engine against the recovery fallback engine: the fallback /// request is dispatched only after `threshold` elapses with the primary still /// running, the first successful transcription wins, and the loser is cancelled. @@ -2624,7 +2640,12 @@ final class DictationViewModel: ObservableObject { } let fallbackTask = Task { @MainActor [logger] in do { - try await Task.sleep(nanoseconds: UInt64(threshold * 1_000_000_000)) + // The caller only starts the race for a convertible threshold. + guard let delay = Self.hedgeDelayNanoseconds(forThreshold: threshold) else { + arbiter.fallbackSkipped() + return + } + try await Task.sleep(nanoseconds: delay) } catch { arbiter.fallbackSkipped() return diff --git a/TypeWhisperTests/TypeWhisperIntegrationTests.swift b/TypeWhisperTests/TypeWhisperIntegrationTests.swift index f57798ca0..7c63081b1 100644 --- a/TypeWhisperTests/TypeWhisperIntegrationTests.swift +++ b/TypeWhisperTests/TypeWhisperIntegrationTests.swift @@ -13267,9 +13267,24 @@ extension TypeWhisperIntegrationTests { XCTAssertFalse(primaryFinished, "the loser was still running when the winner was returned") } + func testHedgeDelayConversionRejectsUnconvertibleThresholds() { + XCTAssertEqual(DictationViewModel.hedgeDelayNanoseconds(forThreshold: 0.1), 100_000_000) + XCTAssertEqual(DictationViewModel.hedgeDelayNanoseconds(forThreshold: 15), 15_000_000_000) + XCTAssertEqual(DictationViewModel.hedgeDelayNanoseconds(forThreshold: 60), 60_000_000_000) + XCTAssertNil(DictationViewModel.hedgeDelayNanoseconds(forThreshold: 60.5)) + XCTAssertNil(DictationViewModel.hedgeDelayNanoseconds(forThreshold: 1e308)) + XCTAssertNil(DictationViewModel.hedgeDelayNanoseconds(forThreshold: 1e12)) + XCTAssertNil(DictationViewModel.hedgeDelayNanoseconds(forThreshold: .infinity)) + XCTAssertNil(DictationViewModel.hedgeDelayNanoseconds(forThreshold: .nan)) + XCTAssertNil(DictationViewModel.hedgeDelayNanoseconds(forThreshold: 0)) + XCTAssertNil(DictationViewModel.hedgeDelayNanoseconds(forThreshold: -1)) + } + @MainActor func testInvalidHedgeThresholdSkipsTheHedgeInsteadOfTrapping() async throws { - for invalid in [Double.infinity, Double.nan, -1.0, 0.0] { + // 1e308 is finite and positive but its nanosecond product is infinite; + // 1e12 s is finite yet far past any sensible hedge and past UInt64 nanoseconds. + for invalid in [Double.infinity, Double.nan, -1.0, 0.0, 1e308, 1e12, 61.0] { var fallbackCalled = false let harness = try makeHedgedDictationViewModel( hedgeThreshold: invalid, From 1760a8b60673df63d7e4c9b504ceb7654ab87d74 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 11 Sep 2026 13:42:49 -0400 Subject: [PATCH 9/9] Describe only the timeout in TranscriptionDeadlineExceeded The error text claimed the recording was kept in Dictation Recovery, which is not true under the 'Immediately' retention policy or after a failed file move. The description now covers the timeout only; the failure path appends the recovery confirmation and the Open Recovery action itself when a file was actually preserved. Two API-path tests cover the timeout feedback with recovery disabled and with a preserved recording, using a transcription mock that hangs on a timer no cancellation can interrupt, and the dictation context harness can now inject a transcription deadline. Co-Authored-By: Claude Fable 5.1 --- .../ViewModels/DictationViewModel.swift | 7 +- .../TypeWhisperIntegrationTests.swift | 137 +++++++++++++++++- 2 files changed, 140 insertions(+), 4 deletions(-) diff --git a/TypeWhisper/ViewModels/DictationViewModel.swift b/TypeWhisper/ViewModels/DictationViewModel.swift index 3b4e730bf..2145687c4 100644 --- a/TypeWhisper/ViewModels/DictationViewModel.swift +++ b/TypeWhisper/ViewModels/DictationViewModel.swift @@ -169,11 +169,14 @@ final class DictationViewModel: ObservableObject { struct TranscriptionDeadlineExceeded: LocalizedError, Equatable { let seconds: TimeInterval + // Describes the timeout only. Whether a recovery recording exists is + // decided by the failure path (retention policy, file move), which + // appends the recovery confirmation and action itself when one does. var errorDescription: String? { let rounded = Int(seconds.rounded()) return localizedAppText( - "Transcription timed out after \(rounded) seconds. The recording was kept in Dictation Recovery.", - de: "Die Transkription hat nach \(rounded) Sekunden das Zeitlimit überschritten. Die Aufnahme wurde in der Diktat-Wiederherstellung behalten." + "Transcription timed out after \(rounded) seconds.", + de: "Die Transkription hat nach \(rounded) Sekunden das Zeitlimit überschritten." ) } } diff --git a/TypeWhisperTests/TypeWhisperIntegrationTests.swift b/TypeWhisperTests/TypeWhisperIntegrationTests.swift index 8a609abf7..8ea1c82bb 100644 --- a/TypeWhisperTests/TypeWhisperIntegrationTests.swift +++ b/TypeWhisperTests/TypeWhisperIntegrationTests.swift @@ -816,6 +816,7 @@ final class TypeWhisperIntegrationTests: XCTestCase { nonisolated(unsafe) private static var _lastLanguageSelection = PluginLanguageSelection() nonisolated(unsafe) private static var _responseText = "transcribed" nonisolated(unsafe) private static var _failureMessage: String? + nonisolated(unsafe) private static var _hangSeconds: TimeInterval? nonisolated(unsafe) private static var _transcribeCallCount = 0 static var lastPrompt: String? { @@ -836,10 +837,27 @@ final class TypeWhisperIntegrationTests: XCTestCase { _lastLanguageSelection = PluginLanguageSelection() _responseText = "transcribed" _failureMessage = nil + _hangSeconds = nil _transcribeCallCount = 0 } } + /// Makes every transcribe call wait on a plain dispatch timer that no Task + /// cancellation can interrupt, i.e. an engine whose transport never aborts. + static func setHang(seconds: TimeInterval) { + promptLock.withLock { + _hangSeconds = seconds + } + } + + private static func hangIfRequested() async { + let seconds = promptLock.withLock { _hangSeconds } + guard let seconds else { return } + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.global().asyncAfter(deadline: .now() + seconds) { continuation.resume() } + } + } + static func setResponseText(_ text: String) { promptLock.withLock { _responseText = text @@ -875,6 +893,7 @@ final class TypeWhisperIntegrationTests: XCTestCase { Self._transcribeCallCount += 1 return (text: Self._responseText, failureMessage: Self._failureMessage) } + await Self.hangIfRequested() if let failureMessage = result.failureMessage { throw PluginTranscriptionError.apiError(failureMessage) } @@ -893,6 +912,7 @@ final class TypeWhisperIntegrationTests: XCTestCase { Self._transcribeCallCount += 1 return (text: Self._responseText, failureMessage: Self._failureMessage) } + await Self.hangIfRequested() if let failureMessage = result.failureMessage { throw PluginTranscriptionError.apiError(failureMessage) } @@ -8512,6 +8532,115 @@ final class TypeWhisperIntegrationTests: XCTestCase { XCTAssertFalse(context.dictationViewModel.actionFeedbackIsError) } + @MainActor + func testTranscriptionTimeoutFeedbackWithoutRecoveryDescribesOnlyTheTimeout() async throws { + let appSupportDirectory = try TestSupport.makeTemporaryDirectory() + let recoveryStore = DictationRecoveryAudioStore( + directory: appSupportDirectory.appendingPathComponent("dictation-recovery", isDirectory: true), + retentionPolicy: .immediately + ) + var dictationContext: DictationContext? + defer { + dictationContext = nil + MockTranscriptionPlugin.reset() + TestSupport.remove(appSupportDirectory) + } + + MockTranscriptionPlugin.reset() + MockTranscriptionPlugin.setHang(seconds: 3.0) + dictationContext = Self.makeDictationContext( + appSupportDirectory: appSupportDirectory, + audioRecordingRecoveryAudioStore: recoveryStore, + transcriptionDeadline: 0.4 + ) + let context = try XCTUnwrap(dictationContext) + let samples = Array(repeating: Float(0.25), count: Int(AudioRecordingService.targetSampleRate)) + context.audioRecordingService.hasMicrophonePermissionOverride = true + context.audioRecordingService.inputAvailabilityOverride = { _ in true } + context.audioRecordingService.startRecordingOverride = {} + context.audioRecordingService.stopRecordingOverride = { _ in samples } + context.textInsertionService.captureActiveAppOverride = { ("Notes", "com.apple.Notes", nil) } + context.textInsertionService.selectedTextOverride = { nil } + + let sessionID = context.dictationViewModel.apiStartRecording() + await context.dictationViewModel.testingWaitForRecordingStart() + recoveryStore.append(samples) + _ = context.dictationViewModel.apiStopRecording() + + for _ in 0..<80 { + if context.dictationViewModel.apiDictationSession(id: sessionID)?.status == .failed { + break + } + try? await Task.sleep(for: .milliseconds(25)) + } + + let expectedTimeout = DictationViewModel.TranscriptionDeadlineExceeded(seconds: 0.4).localizedDescription + XCTAssertEqual(context.dictationViewModel.apiDictationSession(id: sessionID)?.status, .failed) + XCTAssertEqual(context.dictationViewModel.apiDictationSession(id: sessionID)?.error, expectedTimeout) + XCTAssertTrue(recoveryStore.recoveryURLs.isEmpty, "retention 'Immediately' keeps no recovery file") + XCTAssertEqual(context.dictationViewModel.actionFeedbackMessage, expectedTimeout) + XCTAssertFalse(expectedTimeout.contains("Recovery"), "the timeout text must not promise a recovery recording") + XCTAssertNil(context.dictationViewModel.actionFeedbackActionTitle) + XCTAssertTrue(context.dictationViewModel.actionFeedbackIsError) + } + + @MainActor + func testTranscriptionTimeoutFeedbackSurfacesPreservedRecoveryAndOpenAction() async throws { + let appSupportDirectory = try TestSupport.makeTemporaryDirectory() + let recoveryStore = DictationRecoveryAudioStore( + directory: appSupportDirectory.appendingPathComponent("dictation-recovery", isDirectory: true), + retentionPolicy: .never + ) + var dictationContext: DictationContext? + defer { + dictationContext = nil + MockTranscriptionPlugin.reset() + TestSupport.remove(appSupportDirectory) + } + + MockTranscriptionPlugin.reset() + MockTranscriptionPlugin.setHang(seconds: 3.0) + dictationContext = Self.makeDictationContext( + appSupportDirectory: appSupportDirectory, + audioRecordingRecoveryAudioStore: recoveryStore, + transcriptionDeadline: 0.4 + ) + let context = try XCTUnwrap(dictationContext) + let samples = Array(repeating: Float(0.25), count: Int(AudioRecordingService.targetSampleRate)) + context.audioRecordingService.hasMicrophonePermissionOverride = true + context.audioRecordingService.inputAvailabilityOverride = { _ in true } + context.audioRecordingService.startRecordingOverride = {} + context.audioRecordingService.stopRecordingOverride = { _ in samples } + context.textInsertionService.captureActiveAppOverride = { ("Notes", "com.apple.Notes", nil) } + context.textInsertionService.selectedTextOverride = { nil } + + let sessionID = context.dictationViewModel.apiStartRecording() + await context.dictationViewModel.testingWaitForRecordingStart() + recoveryStore.append(samples) + _ = context.dictationViewModel.apiStopRecording() + + for _ in 0..<80 { + if context.dictationViewModel.apiDictationSession(id: sessionID)?.status == .failed { + break + } + try? await Task.sleep(for: .milliseconds(25)) + } + + let expectedTimeout = DictationViewModel.TranscriptionDeadlineExceeded(seconds: 0.4).localizedDescription + let recoveryMessage = try TestSupport.localizedCatalogValueForCurrentLocale( + for: "The recording was saved to Dictation Recovery." + ) + let openRecoveryTitle = try TestSupport.localizedCatalogValueForCurrentLocale(for: "Open Recovery") + XCTAssertEqual(context.dictationViewModel.apiDictationSession(id: sessionID)?.status, .failed) + XCTAssertEqual(recoveryStore.recoveryURLs.count, 1) + XCTAssertEqual( + context.dictationViewModel.actionFeedbackMessage, + "\(expectedTimeout)\n\(recoveryMessage)" + ) + XCTAssertEqual(context.dictationViewModel.actionFeedbackActionTitle, openRecoveryTitle) + XCTAssertTrue(context.dictationViewModel.actionFeedbackIsError) + } + @MainActor func testFailedTranscriptionWithoutNewRecoveryKeepsOriginalFeedback() async throws { let appSupportDirectory = try TestSupport.makeTemporaryDirectory() @@ -9097,7 +9226,8 @@ final class TypeWhisperIntegrationTests: XCTestCase { audioDeviceDefaultInputController: AudioInputDeviceDefaultControlling = CoreAudioInputDeviceDefaultController(), audioRecordingBluetoothInputRouteStabilizer: BluetoothInputRouteStabilizing = CoreAudioBluetoothInputRouteStabilizer(), audioRecordingRecoveryAudioStore: DictationRecoveryAudioStore = DictationRecoveryAudioStore(), - licenseService: LicenseService? = nil + licenseService: LicenseService? = nil, + transcriptionDeadline: TimeInterval? = nil ) -> DictationContext { EventBus.shared = EventBus() PluginManager.shared = PluginManager(appSupportDirectory: appSupportDirectory) @@ -9223,7 +9353,10 @@ final class TypeWhisperIntegrationTests: XCTestCase { speechFeedbackService: speechFeedbackService, accessibilityAnnouncementService: accessibilityAnnouncementService, errorLogService: errorLogService, - mediaPlaybackService: mediaPlaybackService + mediaPlaybackService: mediaPlaybackService, + transcriptionDeadlineProvider: transcriptionDeadline.map { deadline -> DictationViewModel.TranscriptionDeadlineProvider in + { _ in deadline } + } ) dictationViewModel.soundFeedbackEnabled = false dictationViewModel.spokenFeedbackEnabled = false