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 5f2aa8022..289b9c012 100644 --- a/TypeWhisper/Services/SettingsBackupExporter.swift +++ b/TypeWhisper/Services/SettingsBackupExporter.swift @@ -636,7 +636,8 @@ enum SettingsBackupExporter { userDefaults: UserDefaults = .standard, liveFieldTranscriptEnabledDidChange: ((Bool) -> Void)? = nil, cancellationBehaviorDidChange: ((CancellationBehavior) -> Void)? = nil, - recoveryRetentionPolicyDidChange: ((DictationRecoveryRetentionPolicy) -> Void)? = nil + recoveryRetentionPolicyDidChange: ((DictationRecoveryRetentionPolicy) -> Void)? = nil, + dictationRecoveryPreferencesDidChange: (() -> Void)? = nil ) async -> ImportResult { var result = ImportResult() @@ -880,6 +881,13 @@ enum SettingsBackupExporter { forKey: UserDefaultsKeys.dictationRecoveryHedgeThresholdSeconds ) apply(preferences.dictationRecoveryRetentionDays, forKey: UserDefaultsKeys.dictationRecoveryRetentionDays) + if preferences.dictationRecoveryLanguage != nil + || preferences.dictationRecoveryAutomaticFallbackEnabled != nil + || preferences.dictationRecoveryHedgeEnabled != nil + || preferences.dictationRecoveryHedgeThresholdSeconds != nil + || preferences.dictationRecoveryRetentionDays != nil { + dictationRecoveryPreferencesDidChange?() + } if preferences.dictationRecoveryRetentionDays != nil { recoveryRetentionPolicyDidChange?(DictationRecoveryRetentionPolicy.load(from: userDefaults)) } @@ -941,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, @@ -954,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 @@ -968,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 { @@ -999,7 +1013,9 @@ final class SettingsBackupAutomationService { usageStatisticsService: usageStatisticsService, userDefaults: userDefaults, liveFieldTranscriptEnabledDidChange: liveFieldTranscriptEnabledDidChange, - recoveryRetentionPolicyDidChange: recoveryRetentionPolicyDidChange + cancellationBehaviorDidChange: cancellationBehaviorDidChange, + recoveryRetentionPolicyDidChange: recoveryRetentionPolicyDidChange, + dictationRecoveryPreferencesDidChange: dictationRecoveryPreferencesDidChange ) } } diff --git a/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift b/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift index be3561c09..9ccb26649 100644 --- a/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift +++ b/TypeWhisper/ViewModels/DictationRecoveryViewModel.swift @@ -268,6 +268,28 @@ 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 + ) + retentionPolicy = DictationRecoveryRetentionPolicy.load(from: defaults) + 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 566544799..7e68b7df0 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,28 @@ 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.", + de: "Die Transkription hat nach \(rounded) Sekunden das Zeitlimit überschritten." + ) + } + } + + /// 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 { @@ -348,6 +374,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? @@ -459,7 +486,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 @@ -498,6 +526,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, @@ -2386,6 +2417,125 @@ 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 + ) + } + + // 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, + languageSelection: languageSelection, + task: task, + primaryEngineId: primaryEngineId, + primaryCloudModelOverride: primaryCloudModelOverride, + prompt: prompt, + dictionaryTermHints: dictionaryTermHints, + normalizeNumbers: normalizeNumbers + ) + } + 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) + } + } onCancel: { + Task { @MainActor in + arbiter.settle(.failure(CancellationError())) + } + } + 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() + } + } + + 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/TypeWhisper/Views/AdvancedSettingsView.swift b/TypeWhisper/Views/AdvancedSettingsView.swift index b1b203b50..23e1efa42 100644 --- a/TypeWhisper/Views/AdvancedSettingsView.swift +++ b/TypeWhisper/Views/AdvancedSettingsView.swift @@ -671,6 +671,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 72175db60..cca51c6e4 100644 --- a/TypeWhisperTests/FileTranscriptionViewModelTests.swift +++ b/TypeWhisperTests/FileTranscriptionViewModelTests.swift @@ -850,6 +850,49 @@ 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") + + 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 { let defaults = try makeDefaults() defaults.set(true, forKey: UserDefaultsKeys.dictationRecoveryAutomaticFallbackEnabled) diff --git a/TypeWhisperTests/SettingsBackupExporterTests.swift b/TypeWhisperTests/SettingsBackupExporterTests.swift index eb52cfb87..1a707bb2a 100644 --- a/TypeWhisperTests/SettingsBackupExporterTests.swift +++ b/TypeWhisperTests/SettingsBackupExporterTests.swift @@ -687,6 +687,95 @@ 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) + + await importing(makeBackup { $0.dictationRecoveryRetentionDays = 7 }) + 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). diff --git a/TypeWhisperTests/TypeWhisperIntegrationTests.swift b/TypeWhisperTests/TypeWhisperIntegrationTests.swift index 7c63081b1..d8fdd0977 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 @@ -13140,6 +13273,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) { @@ -13205,12 +13339,122 @@ 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 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( + 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