Repository navigation
fix(menu): Swift correctness and warnings - #121
Conversation
tarakanof
left a comment
There was a problem hiding this comment.
Review: #121 (closes #114)
Verdict: fix #1 before merging. The rest are small.
Verified locally on head 9d3ad0d:
swift test --package-path macos: 224 tests pass.xcodebuildDebug withCODE_SIGNING_ALLOWED=NO: the Swift code compiles. The only warnings left are the 2 Combine@Stateones inDashboardView. The build then fails in the "Bundle & sign producer helpers" script, as expected here.staleRefreshFromPreviousServerIsDroppedpins the guard: withguard mine == generationremoved, it fails atAppModelTests.swift:109.
Checked and fine:
ProducerInstallService: Sendable. Both boundary protocols are alreadySendableandfileExistsis@Sendable, so the conformance holds without@unchecked. The@concurrentbatch ops andsnapshot()are awaited from MainActor callers (the SwiftUI view and theTaskinAppEnvironmentinherit MainActor), so the@StateandUserDefaultswrites after eachawaitrun back on main.- Pipe fix. Draining stderr on another thread while stdout is read on the calling one removes the deadlock.
DispatchGroup.wait()orders theDataBoxwrite before the read. AppModelpoll loop.guard let selfreleases the model between iterations, so the loop ends once the model is gone.
Should-fix
-
Retry can ring a reminder twice. This is likely with the current clock link.
POST /v1/reminders/fireholds the response untilpublisher.Notifyfinishes, which can take up to 10 s (cmd/ember/reminders.go:67). The app session usestimeoutIntervalForRequest = 5andtimeoutIntervalForResource = 10(APIClient.swift:82-83). So a slow push to the clock can land on the clock while the app sees a timeout.firethen returns false, the key is never recorded, and the next poll (up to 30 s later, still inside the 90 s grace) sends it again. The user gets a second popup and sound, and withholdthe 15-min ack window is re-armed. The same happens when the clock shows the popup but its ack is lost, soNotifyerrors and the server returns 502. Known server-to-clock loss makes both cases common. Options:- Retry only on errors that prove the popup was never delivered: connection refused,
rateLimited, or a 4xx beforeNotify. Treat a timeout or 502 as "maybe delivered" and record the key. - Give this one request a timeout longer than 10 s.
- Send the dedupe key and have the server drop repeats inside the grace window.
Also update the ARCHITECTURE note so it says which failures are retried.
- Retry only on errors that prove the popup was never delivered: connection refused,
-
Fallback place name changed from state to country.
MKAddressRepresentations.regionNameis the country ("United States" in the SDK header), not the oldadministrativeArea("California"). When no city is found, the Weather tab now shows the country.cityWithContext("Cupertino, CA") is closer to the old behavior. If the country is intended, say so in the PR.
Nits
- Concurrent poll can double-fire. The ledger is now written after the
await, so two concurrentpoll()calls could both passfired.contains. Today only one loop exists. The narrow case: disable then re-enable Reminders while a fire is in flight. The old loop'spollkeeps running aftercancel(), and the new loop can fire the same key. An in-flight set, marked before theawaitand cleared on failure, closes it. ProducersToggleSectionbriefly shows the empty state. Until the firstsnapshot()returns, the view renders.empty: toggle off, "No supported agent CLI detected…", and the toggle is enabled. Considersnapshot: ProducerSnapshot?with a disabled or placeholder state. Also, a late.tasksnapshot can overwrite the oneapplyjust set. This is rare.- Blocking work on the cooperative pool. The
@concurrentops call the synchronous runner andSMAppServiceon a cooperative-pool thread, andreadDataToEndOfFile,waitUntilExitandDispatchGroup.waitblock it. That's acceptable for a handful of agents, but the doc comment could say that the calls block. StubURLProtocolcallsclientfrom a global queue. Its callbacks now run off the thread that calledstartLoading, which breaks theURLProtocolcontract. It works today, but it's a possible source of flaky tests. Only the stale-refresh test needs it.- No test for the watcher's record-only-on-success rule.
ReminderWatcheris in the app target, so nothing tests that rule. The ledger tests cover only the container.
installAll/uninstallAll spawned the producer binaries and waited on them, plus SMAppService IPC, on the MainActor, so toggling "Report this Mac's agent activity" froze the UI. The launch-time reconcile did the same inside AppEnvironment.init and swallowed its error. ProducerInstallService is now a nonisolated Sendable type whose batch operations are @Concurrent. The Settings section reads a snapshot off the main thread instead of doing filesystem and SMAppService reads on every render. A failed reconcile is logged and retried next launch. ProcessCommandRunner also drains stderr concurrently: reading the pipes one after the other deadlocks once the child fills the unread pipe's buffer. Refs #114
The watcher marked an occurrence fired before POST /v1/reminders/fire ran, so a reminder due while the server was down, restarting or rate-limited never reached the clock, even though the 90s grace window allowed a retry. It is now recorded only on success; the last failure is kept for the UI. The fired set grew for the app's lifetime; the new ReminderFiredLedger drops entries a day past due. NSLog wrote every reminder title to the unified log in clear text on each fire; os.Logger now marks titles .private. Refs #114
The poll loop, menu actions and reloadConnection all call refresh() on the MainActor and interleave across awaits, so a slow response from the previous server could land after the new one's and overwrite sessions and connectedness. A generation counter bumped by configure() and each refresh() now discards results from a superseded refresh. setApp used try?, so a rejected toggle (401, 429) just snapped back. The failure is now kept in appToggleError for the menu to show. The test stub answers off URLSession's shared loader thread, so a handler can hold a response in flight without stalling other tests. Refs #114
- AppEnvironment.feedBot: [weak self] sat on the inner Task while the onChange closure captured self strongly, so it did nothing. - LocationService: CLGeocoder is deprecated in macOS 26 (the deployment target); use MKReverseGeocodingRequest. - activate(ignoringOtherApps:) is deprecated since macOS 14. - PreviewCanvas stores a Combine publisher in @State without importing Combine, which the Swift 6.2 @State macro warns about. DashboardView has the same Combine warning; it is being rewritten in another ticket, so it is left alone here. Refs #114
POST /v1/reminders/fire holds the response while it pushes to the clock (up to 10s), and the clock link is lossy, so the app can see a failure for a popup that did ring. A retry then rang it twice and re-armed the 15-minute hold window. A request carrying an Idempotency-Key already claimed in the last 10 minutes now gets 200 without another push, and before the hold window is armed. A failed push releases the key so an honest retry still works. Requests without the header behave as before, and old servers ignore the header, so app and server can be updated in either order. Refs #114
Retrying every failed fire could ring a reminder twice. The app timed out at 5s while the server legitimately holds the request up to 10s pushing to the clock, and a 502 after a lost clock ack can still mean the popup showed. The fire now uses a 20s session and sends the occurrence key as Idempotency-Key. ReminderFireOutcome (EmberKit) retries only when the failure proves nothing was sent: connection refused or no route, not configured, 429, or another 4xx. A timeout or 5xx counts as delivered. ReminderFireTracker claims a key before the request goes out, so a poll overlapping an in-flight fire (disable and re-enable) cannot send it again. The record-on-outcome rule now lives in EmberKit, where it is tested. Refs #114
Before the first off-main snapshot the section rendered an empty one: "No supported agent CLI detected" with an enabled toggle. A slow initial read could also land after, and overwrite, the fresher one taken after install/uninstall. The section now shows "Checking agents…" with the toggle disabled, and applies a read only if no newer read started. Also notes that the @Concurrent install ops block a cooperative-pool thread while they run. Refs #114
MKAddressRepresentations.regionName is the country, whereas the old CLPlacemark fallback (administrativeArea) was the state, so a fix with no city showed "United States". Fall back to cityWithContext instead. Refs #114
The stub ran the handler on a global queue and called the URLProtocolClient from there, which breaks the URLProtocol contract and could make tests flaky. The handler still runs off the loader thread, so a test can hold a response in flight, but the callbacks are now delivered on the thread that called startLoading, through its run loop. Refs #114
9d3ad0d to
704e9dd
Compare
Closes #114
What changed and why
ProducerInstallServiceis now a nonisolatedSendabletype.installAll/uninstallAll/reconcileAfterUpdateare@concurrent async, so the process spawns,waitUntilExitand SMAppService IPC no longer freeze the UI. A newsnapshot()letsProducersToggleSectionread detection and status off-main once, instead of on every render. The launch reconcile runs in a Task and logs failures (it used totry?insideinit).ProcessCommandRunnerdrains stderr concurrently to avoid a pipe-buffer deadlock.ReminderFiredLedger(EmberKit) prunes entries a day past due.NSLogis replaced byos.Loggerwith titles marked.private.lastFireErroris exposed for the UI.configureserver.setAppfailures land inappToggleErrorinstead of being swallowed. The poll loop exits once the model is deallocated.AppEnvironment.feedBotweak capture,CLGeocoder→MKReverseGeocodingRequest,activate(ignoringOtherApps:)→activate()inEmberApp.swift, andimport CombineinPreviewCanvas.swift.Task.sleep(nanoseconds:)→Task.sleep(for:).Out-of-scope files touched (minimal hunks)
Settings/ProducersToggleSection.swift: needed to await the async install API and use the snapshot.Settings/PreviewCanvas.swift: a one-lineimport Combine.Tests/StubURLProtocol.swift: runs handlers on a global queue, so a test can hold one response in flight, and calls the client back on the loader thread.Left for other tickets
DashboardView.swiftstill has the Combine@Statewarning (2 lines). It is excluded here.MenuBarContentView.swift:57,61still callsactivate(ignoringOtherApps:)(soft-deprecated, no warning). It is excluded here.appToggleErrororlastFireErroryet.Review follow-ups (after rebase onto #118)
POST /v1/reminders/firebyIdempotency-Key. Keys live 10 min in memory. A duplicate gets 200 with no push and no re-armed hold window, and a failed push releases the key. The app sends the key and uses a 20s session, where the old one timed out at 5s against the server's 10s push. It retries only when the failure proves nothing was sent (ReminderFireOutcome): refused or no route, not configured, 429, or another 4xx. A timeout or 5xx counts as delivered. Old servers ignore the header.ReminderFireTracker(EmberKit, tested) claims the key before the request, so a disable/re-enable cycle can't double-fire.cityName ?? cityWithContext. It no longer falls back toregionName, which is the country, so a fix with no city yields no name.perform(_:on:).@concurrentops block a pool thread.Test evidence
swift test --package-path macos: 230 tests pass.go vetandgo test ./... -racepass; new Go tests cover duplicate key, distinct or missing keys, release on failed push, and TTL pruning. New tests: stale refresh dropped, which fails without the guard; setApp error surfaced and cleared; batch ops run off the main thread; snapshot; ledger record and prune.xcodebuildDebug withCODE_SIGNING_ALLOWED=NOcompiles. The only remaining warnings are the 2 fromDashboardView.swift; the baseline had 7. The build then fails only in the Developer-ID producer sign script, which is expected in this environment.Needs on-device check
Toggle Agent reporting on and off: the UI should stay responsive, and the rows should update after the change.
Weather tab "detect location": the place name now comes from MapKit (
cityName ?? cityWithContext).Reminder fire against the live server: one popup per occurrence; the server log shows
reminder fire duplicateon any retry.