Stop the sync poll from rebuilding the panel every 5 s
@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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -67,7 +67,9 @@ struct ComposerView: NSViewRepresentable {
|
||||
textView.string = text
|
||||
updateHeight(textView)
|
||||
}
|
||||
if textView.placeholderText != placeholder {
|
||||
textView.placeholderText = placeholder
|
||||
}
|
||||
if isFocused, scrollView.window?.firstResponder != textView {
|
||||
scrollView.window?.makeFirstResponder(textView)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user