From aebc4eb7ba66a83b464a48b0ec004c289279f63b Mon Sep 17 00:00:00 2001 From: Owen Gretzinger Date: Tue, 5 Aug 2025 11:57:27 -0400 Subject: [PATCH] fix: auto refresh websocket session (#51) --- meetingnotes/Managers/AudioManager.swift | 119 +++++++++++++++--- meetingnotes/Services/ErrorHandler.swift | 1 + .../ViewModels/MeetingViewModel.swift | 7 ++ 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/meetingnotes/Managers/AudioManager.swift b/meetingnotes/Managers/AudioManager.swift index d8fba01..491414b 100644 --- a/meetingnotes/Managers/AudioManager.swift +++ b/meetingnotes/Managers/AudioManager.swift @@ -44,6 +44,9 @@ class AudioManager: NSObject, ObservableObject { // Add ping timers to keep WebSocket connections alive private var pingTimers: [AudioSource: Timer] = [:] private var cancellables = Set() + + // Session refresh timers to prevent 30-minute expiry + private var sessionRefreshTimers: [AudioSource: Timer] = [:] private override init() { super.init() @@ -136,6 +139,10 @@ class AudioManager: NSObject, ObservableObject { pingTimers.values.forEach { $0.invalidate() } pingTimers.removeAll() + // Invalidate session refresh timers + sessionRefreshTimers.values.forEach { $0.invalidate() } + sessionRefreshTimers.removeAll() + // Reset state // (isRecording already cleared in stopRecording) @@ -407,9 +414,9 @@ class AudioManager: NSObject, ObservableObject { print("Audio tap was invalidated.") if !self.isRestartingSystemTap { - DispatchQueue.main.async { - print("Tap invalidated unexpectedly. Stopping recording.") - self.stopRecording() + print("Tap invalidated unexpectedly. Restarting system audio tap.") + Task { + await self.restartSystemAudioTap() } } else { print("Tap invalidated as part of a restart. Not stopping recording.") @@ -451,6 +458,10 @@ class AudioManager: NSObject, ObservableObject { pingTimers.values.forEach { $0.invalidate() } pingTimers.removeAll() + // Invalidate session refresh timers + sessionRefreshTimers.values.forEach { $0.invalidate() } + sessionRefreshTimers.removeAll() + print("Recording stopped") } @@ -520,6 +531,15 @@ class AudioManager: NSObject, ObservableObject { } pingTimers[source] = pingTimer + // Set up session refresh timer to prevent 30-minute expiry (refresh after 28 minutes) + sessionRefreshTimers[source]?.invalidate() + let sessionRefreshTimer = Timer.scheduledTimer(withTimeInterval: 28 * 60.0, repeats: false) { [weak self] _ in + guard let self = self, self.isRecording else { return } + print("📝 Proactively refreshing session for \(source) to prevent expiry...") + self.connectToOpenAIRealtime(source: source) + } + sessionRefreshTimers[source] = sessionRefreshTimer + let thisSession = sessionID // Monitor connection state (ignore if session changed or recording stopped) DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self, weak task] in @@ -614,15 +634,31 @@ class AudioManager: NSObject, ObservableObject { let errorMsg = self.handleWebSocketError(error, source: source) print("❌ Receive error (\(source)): \(error)") - DispatchQueue.main.async { - self.errorMessage = errorMsg - } + // Check if this is a session expiry - if so, don't show as persistent error + let isSessionExpiry = errorMsg == ErrorMessage.sessionExpired - // Only attempt reconnect for network errors, not API errors - if ErrorHandler.shared.shouldRetry(error) { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - guard let self = self, self.isRecording, self.sessionID == sessionID else { return } - self.connectToOpenAIRealtime(source: source) + if isSessionExpiry { + // For session expiry, show temporary message + DispatchQueue.main.async { + self.errorMessage = errorMsg + // Clear the message after a few seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if self.errorMessage == errorMsg { + self.errorMessage = nil + } + } + } + } else { + DispatchQueue.main.async { + self.errorMessage = errorMsg + } + + // Only attempt reconnect for network errors, not API errors + if ErrorHandler.shared.shouldRetry(error) { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + guard let self = self, self.isRecording, self.sessionID == sessionID else { return } + self.connectToOpenAIRealtime(source: source) + } } } } @@ -630,7 +666,21 @@ class AudioManager: NSObject, ObservableObject { } private func handleWebSocketError(_ error: Error, source: AudioSource) -> String { - // Check for WebSocket close codes first + // Check for session expiry in error description first + let errorDescription = error.localizedDescription.lowercased() + if errorDescription.contains("session hit the maximum duration") || + errorDescription.contains("session expired") { + // Handle session expiry by automatically restarting the connection + print("📝 Session expired for \(source) (WebSocket error), attempting to restart connection...") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + guard let self = self, self.isRecording else { return } + self.connectToOpenAIRealtime(source: source) + } + // Return session expired message but don't stop recording + return ErrorMessage.sessionExpired + } + + // Check for WebSocket close codes if let closeCode = (error as NSError?)?.userInfo["closeCode"] as? Int { return ErrorHandler.shared.handleWebSocketCloseCode(closeCode) } @@ -666,10 +716,51 @@ class AudioManager: NSObject, ObservableObject { userFriendlyMessage = ErrorMessage.apiServerError case "access_denied", "forbidden": userFriendlyMessage = ErrorMessage.accessForbidden + case "session_expired": + // Handle session expiry by automatically restarting the connection + print("📝 Session expired for \(source), attempting to restart connection...") + userFriendlyMessage = ErrorMessage.sessionExpired + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + guard let self = self, self.isRecording else { return } + self.connectToOpenAIRealtime(source: source) + } + // Show informational message but don't stop recording + DispatchQueue.main.async { + self.errorMessage = userFriendlyMessage + // Clear the message after a few seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if self.errorMessage == userFriendlyMessage { + self.errorMessage = nil + } + } + } + return default: + // Check for session expiry in the error message + if errorMessage.lowercased().contains("session hit the maximum duration") || + errorMessage.lowercased().contains("session expired") { + // Handle session expiry by automatically restarting the connection + print("📝 Session expired for \(source), attempting to restart connection...") + userFriendlyMessage = ErrorMessage.sessionExpired + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + guard let self = self, self.isRecording else { return } + self.connectToOpenAIRealtime(source: source) + } + // Show informational message but don't stop recording + DispatchQueue.main.async { + self.errorMessage = userFriendlyMessage + // Clear the message after a few seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { + if self.errorMessage == userFriendlyMessage { + self.errorMessage = nil + } + } + } + return + } // Check if this is a transcription failure (often indicates insufficient funds) - if errorMessage.lowercased().contains("input transcription failed") || - errorMessage.lowercased().contains("transcription failed") { + else if errorMessage.lowercased().contains("input transcription failed") || + errorMessage.lowercased().contains("transcription failed") { userFriendlyMessage = "\(errorMessage)\n\nNote: This error typically occurs when your OpenAI account has insufficient funds. Please check your account balance and add credits if needed." } else { userFriendlyMessage = "Transcription error: \(errorMessage)" diff --git a/meetingnotes/Services/ErrorHandler.swift b/meetingnotes/Services/ErrorHandler.swift index 58b4e71..215f53e 100644 --- a/meetingnotes/Services/ErrorHandler.swift +++ b/meetingnotes/Services/ErrorHandler.swift @@ -198,4 +198,5 @@ enum ErrorMessage { static let requestTooLarge = "Request too large. Please try again." static let unsupportedData = "Unsupported data format. Please update the app." static let connectionLost = "Connection lost. Please try again." + static let sessionExpired = "Session expired and has been automatically renewed. Transcription will continue." } \ No newline at end of file diff --git a/meetingnotes/ViewModels/MeetingViewModel.swift b/meetingnotes/ViewModels/MeetingViewModel.swift index de68a13..529726f 100644 --- a/meetingnotes/ViewModels/MeetingViewModel.swift +++ b/meetingnotes/ViewModels/MeetingViewModel.swift @@ -100,6 +100,7 @@ class MeetingViewModel: ObservableObject { Publishers.CombineLatest(recordingSessionManager.$isRecording, recordingSessionManager.$activeMeetingId) .sink { [weak self] (isRecording, activeMeetingId) in guard let self = self else { return } + // If recording started for this meeting, end starting state if isRecording && activeMeetingId == self.meeting.id { self.isStartingRecording = false @@ -113,6 +114,12 @@ class MeetingViewModel: ObservableObject { recordingSessionManager.$errorMessage .compactMap { $0 } .sink { [weak self] errorMessage in + // Suppress non-critical, self-healing errors that should not distract the user + let lowercased = errorMessage.lowercased() + if errorMessage == ErrorMessage.sessionExpired || lowercased.contains("socket is not connected") { + print("â„šī¸ Suppressed non-critical error: \(errorMessage)") + return + } self?.errorMessage = errorMessage print("🚨 Recording Session Manager Error: \(errorMessage)") }