From ee4ce0534d01048a5c9cf7b7d0b22843935583a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 01:40:59 +0000 Subject: [PATCH] fix: bound WebView memory growth and prevent leaked continuations The ChatGPT-Web provider's hidden WKWebView runs ChatGPT's SPA for the life of the app and never reloads, so its WebContent process grows unbounded across dictations (each one marshals a base64 audio payload through callAsyncJavaScript). Reclaim that memory by reloading the warmed page after a long idle stretch; the reload self-gates on idle time and never interrupts an in-flight request. Cookies persist in the data store, so the page stays logged in. Also add didFail/didFailProvisionalNavigation handlers: without them, a failed navigation left every task parked in waitUntilReady() unresumed, leaking those continuations and hanging warmBridge. Finally, remove the orphaned temp .m4a when AVAudioRecorder.record() fails. --- Sources/AppState.swift | 6 +++++ Sources/Audio/AudioRecorder.swift | 2 ++ Sources/Bridge/ChatGPTBridge.swift | 41 ++++++++++++++++++++++++++++-- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/Sources/AppState.swift b/Sources/AppState.swift index 10364fc..a669dab 100644 --- a/Sources/AppState.swift +++ b/Sources/AppState.swift @@ -136,6 +136,12 @@ final class AppState: ObservableObject { if permsJustChanged, settings.playSounds { NSSound(named: "Glass")?.play() } } } + + // Reclaim the WebView's WebContent memory when idle (ChatGPT provider only). + // Cheap to call every tick; it self-gates on idle time and never fires mid-use. + if settings.provider == .chatgpt, !busy, !recorder.isRecording { + bridge.recycleIfIdle() + } } /// The backend selected in Settings. diff --git a/Sources/Audio/AudioRecorder.swift b/Sources/Audio/AudioRecorder.swift index 5acedd5..39544fd 100644 --- a/Sources/Audio/AudioRecorder.swift +++ b/Sources/Audio/AudioRecorder.swift @@ -34,6 +34,8 @@ final class AudioRecorder: NSObject, ObservableObject, AVAudioRecorderDelegate { rec.delegate = self rec.isMeteringEnabled = true guard rec.record() else { + // AVAudioRecorder may have already created the file on disk — don't orphan it. + try? FileManager.default.removeItem(at: url) throw NSError(domain: "LizardType", code: 1, userInfo: [NSLocalizedDescriptionKey: "AVAudioRecorder failed to start (mic permission?)"]) } diff --git a/Sources/Bridge/ChatGPTBridge.swift b/Sources/Bridge/ChatGPTBridge.swift index 989e6c2..c9225fa 100644 --- a/Sources/Bridge/ChatGPTBridge.swift +++ b/Sources/Bridge/ChatGPTBridge.swift @@ -33,6 +33,11 @@ final class ChatGPTBridge: NSObject, WKNavigationDelegate { private var tokenFetchedAt: Date = .distantPast private let tokenTTL: TimeInterval = 240 + /// Last time the bridge did real work, and last time we recycled the page. + /// Used to reclaim WebContent memory when the app has been idle (see `recycleIfIdle`). + private var lastActivity = Date() + private var lastReload = Date() + override init() { let cfg = WKWebViewConfiguration() cfg.websiteDataStore = .default() @@ -64,11 +69,41 @@ final class ChatGPTBridge: NSObject, WKNavigationDelegate { // small settle for any CF/app bootstrapping try? await Task.sleep(nanoseconds: 1_500_000_000) self.isReady = true - let waiters = self.readyWaiters; self.readyWaiters.removeAll() - for w in waiters { w.resume() } + self.resumeWaiters() } } + // If a navigation fails we must still resume anyone parked in `waitUntilReady()`, + // otherwise those continuations (and their captured state) leak and `warmBridge` hangs. + nonisolated func webView(_ wv: WKWebView, didFail nav: WKNavigation!, withError error: Error) { + Task { @MainActor in self.resumeWaiters() } + } + + nonisolated func webView(_ wv: WKWebView, didFailProvisionalNavigation nav: WKNavigation!, withError error: Error) { + Task { @MainActor in self.resumeWaiters() } + } + + private func resumeWaiters() { + let waiters = readyWaiters; readyWaiters.removeAll() + for w in waiters { w.resume() } + } + + /// Reclaim WebContent-process memory by reloading the warmed page after a long + /// idle stretch. ChatGPT's SPA never frees what it accumulates across dictations, + /// so a fresh load bounds the growth. No-op while busy / recently used so it never + /// interrupts an in-flight request. Cookies persist in the data store, so the + /// reloaded page stays logged in. + func recycleIfIdle(idleThreshold: TimeInterval = 600) { + guard isReady else { return } // mid-load: leave it alone + let now = Date() + guard now.timeIntervalSince(lastActivity) > idleThreshold, + now.timeIntervalSince(lastReload) > idleThreshold else { return } + lastReload = now + isReady = false + cachedToken = nil + webView.reload() + } + // MARK: - Auth @discardableResult @@ -99,6 +134,7 @@ final class ChatGPTBridge: NSObject, WKNavigationDelegate { /// POST audio to /backend-api/transcribe. Returns the raw transcript text. func transcribe(audioURL: URL, language: String) async throws -> String { guard isReady else { throw BridgeError.notReady } + lastActivity = Date() let data = try Data(contentsOf: audioURL) let b64 = data.base64EncodedString() let name = audioURL.lastPathComponent @@ -151,6 +187,7 @@ final class ChatGPTBridge: NSObject, WKNavigationDelegate { /// Send `prompt + raw` to /backend-api/conversation; return the cleaned text. func cleanup(raw: String, prompt: String, model: String, language: String) async throws -> String { guard isReady else { throw BridgeError.notReady } + lastActivity = Date() let message = Prompts.cleanupMessage(prompt: prompt, raw: raw) let token = try await accessToken() let js = """