From d665d493af9ded4c866346992ec85a9cb3d3ff54 Mon Sep 17 00:00:00 2001 From: TZGyn Date: Thu, 22 Oct 2026 23:55:32 +0800 Subject: [PATCH] Stop the sync poll from rebuilding the panel every 5 s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Published fires even when the assigned value is unchanged, so the poll's unconditional lastError = nil invalidated PanelView — which observes SyncService — every 5 seconds, forever, hidden or visible. Over five days that was ~185 CPU-minutes and ~190 MB, and clicks started to lag. - Route poll state through setError, which skips equal values. - Watch SyncService from a tiny SyncDot view instead of the panel body. - Skip the URL cache on polls and give them timeouts. - Only reassign the composer placeholder when it changed. --- AGENTS.md | 8 ++++++++ Sources/Prompt/ComposerView.swift | 4 +++- Sources/Prompt/PanelView.swift | 16 +++++++++++++--- Sources/Prompt/SyncService.swift | 32 +++++++++++++++++++++++++------ 4 files changed, 50 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 043b6d9..73714ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,6 +177,14 @@ brew upgrade --cask prompt ## Gotchas +- **`@Published` publishes on every assignment, even when the value is + unchanged.** The 5-second sync poll used to assign `lastError = nil` + unconditionally; `PanelView` observes `SyncService`, so the entire panel + (rows, overlays, composer) rebuilt every 5 seconds, forever. Over five days + of uptime that was ~185 CPU-minutes and ~190 MB of footprint, and every + click started to lag. Poll state now goes through `setError`, which no-ops + when equal, and the header's sync dot is its own view so sync publishes + cannot invalidate the list. - **Never assign `NSApp.mainMenu`.** SwiftUI installs a menu whose Edit items are how AppKit routes Cmd+C/V/X/A/Z; replacing it with a hand-built menu silently breaks pasting, copying and select-all in the composer (this shipped diff --git a/Sources/Prompt/ComposerView.swift b/Sources/Prompt/ComposerView.swift index fb81e09..f5f43ad 100644 --- a/Sources/Prompt/ComposerView.swift +++ b/Sources/Prompt/ComposerView.swift @@ -67,7 +67,9 @@ struct ComposerView: NSViewRepresentable { textView.string = text updateHeight(textView) } - textView.placeholderText = placeholder + if textView.placeholderText != placeholder { + textView.placeholderText = placeholder + } if isFocused, scrollView.window?.firstResponder != textView { scrollView.window?.makeFirstResponder(textView) } diff --git a/Sources/Prompt/PanelView.swift b/Sources/Prompt/PanelView.swift index 022987a..ea316a0 100644 --- a/Sources/Prompt/PanelView.swift +++ b/Sources/Prompt/PanelView.swift @@ -5,7 +5,6 @@ import UniformTypeIdentifiers struct PanelView: View { @ObservedObject var store: Store @ObservedObject private var sync = SyncService.shared - @State private var composer = "" @State private var composerFocused = false @State private var composerHeight: CGFloat = ComposerView.oneLineHeight @@ -84,8 +83,7 @@ struct PanelView: View { Spacer() Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: { - Image(systemName: "arrow.triangle.2.circlepath") - .foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor) + SyncDot() } .buttonStyle(.plain).help("Sync") Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") } @@ -881,3 +879,15 @@ struct PanelView: View { return (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string) } } + +/// Sync status lives in its own view so a `SyncService` publish cannot +/// invalidate the panel's list. Watching it from `PanelView`'s body meant every +/// sync event — including the 5-second poll — re-rendered every row. +private struct SyncDot: View { + @ObservedObject private var sync = SyncService.shared + + var body: some View { + Image(systemName: "arrow.triangle.2.circlepath") + .foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor) + } +} diff --git a/Sources/Prompt/SyncService.swift b/Sources/Prompt/SyncService.swift index 50f3397..199873e 100644 --- a/Sources/Prompt/SyncService.swift +++ b/Sources/Prompt/SyncService.swift @@ -193,13 +193,20 @@ final class SyncService: ObservableObject { private func poll() { guard !isSyncing, let code, let versionURL = endpoint("/api/sync/\(code)/version") else { return } + // A cached /version would keep reporting the version the client already + // has, so polls must always hit the network. The responses are a few + // bytes; letting CFNetwork write them to the disk cache every 5 s only + // adds churn. + var versionRequest = URLRequest(url: versionURL) + versionRequest.cachePolicy = .reloadIgnoringLocalCacheData + versionRequest.timeoutInterval = 15 Task { do { // Cheap poll first: the full payload can be megabytes of // base64 images, so only download it when the version moved. - let (versionData, versionResponse) = try await URLSession.shared.data(from: versionURL) + let (versionData, versionResponse) = try await URLSession.shared.data(for: versionRequest) if (versionResponse as? HTTPURLResponse)?.statusCode == 404 { - lastError = "Sync group no longer exists on the server" + setError("Sync group no longer exists on the server") return } guard let versionHTTP = versionResponse as? HTTPURLResponse, versionHTTP.statusCode == 200 else { @@ -208,12 +215,15 @@ final class SyncService: ObservableObject { } let remoteVersion = try JSONDecoder().decode([String: Int64].self, from: versionData)["version"] ?? 0 guard remoteVersion > lastVersion else { - lastError = nil + setError(nil) return } syncLog("poll remote v\(remoteVersion) local v\(lastVersion) — fetching payload") guard let payloadURL = endpoint("/api/sync/\(code)") else { return } - let (data, response) = try await URLSession.shared.data(from: payloadURL) + var payloadRequest = URLRequest(url: payloadURL) + payloadRequest.cachePolicy = .reloadIgnoringLocalCacheData + payloadRequest.timeoutInterval = 30 + let (data, response) = try await URLSession.shared.data(for: payloadRequest) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return } let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data) if let payload = remote.data { @@ -221,15 +231,25 @@ final class SyncService: ObservableObject { } else { lastVersion = remote.version } - lastError = nil + setError(nil) } catch { syncLog("poll failed: \(error.localizedDescription)") // Polling errors are transient; stay quiet unless nothing ever worked. - if lastSyncedAt == nil { lastError = "Cannot reach sync server" } + if lastSyncedAt == nil { setError("Cannot reach sync server") } } } } + /// `@Published` fires on every assignment, even when the value is + /// unchanged. The 5-second poll would otherwise republish `lastError` + /// forever, and since `PanelView` observes this object, the whole panel + /// (list, rows, overlays) re-rendered every 5 seconds — the app grew to + /// ~190 MB and ~90 ms of layout per poll over five days of uptime. + private func setError(_ message: String?) { + guard lastError != message else { return } + lastError = message + } + private func restartPolling() { pollTimer?.invalidate() pollTimer = nil