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
|
## 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
|
- **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
|
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
|
silently breaks pasting, copying and select-all in the composer (this shipped
|
||||||
|
|||||||
@@ -67,7 +67,9 @@ struct ComposerView: NSViewRepresentable {
|
|||||||
textView.string = text
|
textView.string = text
|
||||||
updateHeight(textView)
|
updateHeight(textView)
|
||||||
}
|
}
|
||||||
textView.placeholderText = placeholder
|
if textView.placeholderText != placeholder {
|
||||||
|
textView.placeholderText = placeholder
|
||||||
|
}
|
||||||
if isFocused, scrollView.window?.firstResponder != textView {
|
if isFocused, scrollView.window?.firstResponder != textView {
|
||||||
scrollView.window?.makeFirstResponder(textView)
|
scrollView.window?.makeFirstResponder(textView)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import UniformTypeIdentifiers
|
|||||||
struct PanelView: View {
|
struct PanelView: View {
|
||||||
@ObservedObject var store: Store
|
@ObservedObject var store: Store
|
||||||
@ObservedObject private var sync = SyncService.shared
|
@ObservedObject private var sync = SyncService.shared
|
||||||
|
|
||||||
@State private var composer = ""
|
@State private var composer = ""
|
||||||
@State private var composerFocused = false
|
@State private var composerFocused = false
|
||||||
@State private var composerHeight: CGFloat = ComposerView.oneLineHeight
|
@State private var composerHeight: CGFloat = ComposerView.oneLineHeight
|
||||||
@@ -84,8 +83,7 @@ struct PanelView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: {
|
Button { withAnimation(.easeInOut(duration: 0.15)) { showSync.toggle() } } label: {
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
SyncDot()
|
||||||
.foregroundStyle(sync.code == nil ? Color.secondary : Color.accentColor)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain).help("Sync")
|
.buttonStyle(.plain).help("Sync")
|
||||||
Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") }
|
Button { toggleSearch() } label: { Image(systemName: "magnifyingglass") }
|
||||||
@@ -881,3 +879,15 @@ struct PanelView: View {
|
|||||||
return (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string)
|
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() {
|
private func poll() {
|
||||||
guard !isSyncing, let code, let versionURL = endpoint("/api/sync/\(code)/version") else { return }
|
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 {
|
Task {
|
||||||
do {
|
do {
|
||||||
// Cheap poll first: the full payload can be megabytes of
|
// Cheap poll first: the full payload can be megabytes of
|
||||||
// base64 images, so only download it when the version moved.
|
// 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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
guard let versionHTTP = versionResponse as? HTTPURLResponse, versionHTTP.statusCode == 200 else {
|
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
|
let remoteVersion = try JSONDecoder().decode([String: Int64].self, from: versionData)["version"] ?? 0
|
||||||
guard remoteVersion > lastVersion else {
|
guard remoteVersion > lastVersion else {
|
||||||
lastError = nil
|
setError(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
syncLog("poll remote v\(remoteVersion) local v\(lastVersion) — fetching payload")
|
syncLog("poll remote v\(remoteVersion) local v\(lastVersion) — fetching payload")
|
||||||
guard let payloadURL = endpoint("/api/sync/\(code)") else { return }
|
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 }
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return }
|
||||||
let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data)
|
let remote = try JSONDecoder().decode(SyncGetResponse.self, from: data)
|
||||||
if let payload = remote.data {
|
if let payload = remote.data {
|
||||||
@@ -221,15 +231,25 @@ final class SyncService: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
lastVersion = remote.version
|
lastVersion = remote.version
|
||||||
}
|
}
|
||||||
lastError = nil
|
setError(nil)
|
||||||
} catch {
|
} catch {
|
||||||
syncLog("poll failed: \(error.localizedDescription)")
|
syncLog("poll failed: \(error.localizedDescription)")
|
||||||
// Polling errors are transient; stay quiet unless nothing ever worked.
|
// 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() {
|
private func restartPolling() {
|
||||||
pollTimer?.invalidate()
|
pollTimer?.invalidate()
|
||||||
pollTimer = nil
|
pollTimer = nil
|
||||||
|
|||||||
Reference in New Issue
Block a user