Skip to content

fix(menu): Swift correctness and warnings - #121

Merged
tarakanof merged 9 commits into
overhaul/ui-ng-2026-09from
fix/114-swift-correctness
Sep 26, 2026
Merged

tarakanof merged 9 commits into
overhaul/ui-ng-2026-09from
fix/114-swift-correctness

Conversation

@tarakanof

@tarakanof tarakanof commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Closes #114

What changed and why

  • Producer install off the main thread. ProducerInstallService is now a nonisolated Sendable type. installAll/uninstallAll/reconcileAfterUpdate are @concurrent async, so the process spawns, waitUntilExit and SMAppService IPC no longer freeze the UI. A new snapshot() lets ProducersToggleSection read detection and status off-main once, instead of on every render. The launch reconcile runs in a Task and logs failures (it used to try? inside init). ProcessCommandRunner drains stderr concurrently to avoid a pipe-buffer deadlock.
  • Reminders. An occurrence is retried inside the 90s grace window only when the failure proves nothing was sent (see follow-ups below). ReminderFiredLedger (EmberKit) prunes entries a day past due. NSLog is replaced by os.Logger with titles marked .private. lastFireError is exposed for the UI.
  • AppModel. A generation counter drops results from a superseded refresh or a pre-configure server. setApp failures land in appToggleError instead of being swallowed. The poll loop exits once the model is deallocated.
  • Warnings/deprecations. AppEnvironment.feedBot weak capture, CLGeocoder → MKReverseGeocodingRequest, activate(ignoringOtherApps:) → activate() in EmberApp.swift, and import Combine in PreviewCanvas.swift. Task.sleep(nanoseconds:) → Task.sleep(for:).
  • Docs: ARCHITECTURE Reminders section notes the retry behavior and log redaction.

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-line import 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.swift still has the Combine @State warning (2 lines). It is excluded here.
  • MenuBarContentView.swift:57,61 still calls activate(ignoringOtherApps:) (soft-deprecated, no warning). It is excluded here.
  • No UI shows appToggleError or lastFireError yet.

Review follow-ups (after rebase onto #118)

  • Double reminder. The server dedupes POST /v1/reminders/fire by Idempotency-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.
  • In-flight claim. ReminderFireTracker (EmberKit, tested) claims the key before the request, so a disable/re-enable cycle can't double-fire.
  • Place-name fallback. It is now cityName ?? cityWithContext. It no longer falls back to regionName, which is the country, so a fix with no city yields no name.
  • ProducersToggleSection. Shows "Checking agents…" with the toggle disabled until the first read. A read applies only if no newer read started.
  • StubURLProtocol. Client callbacks now run on the loader thread via perform(_:on:).
  • Doc comment. Notes that the @concurrent ops block a pool thread.
  • ARCHITECTURE and AGENTS document the header and the retry rules.

Test evidence

  • swift test --package-path macos: 230 tests pass. go vet and go test ./... -race pass; 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.
  • xcodebuild Debug with CODE_SIGNING_ALLOWED=NO compiles. The only remaining warnings are the 2 from DashboardView.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 duplicate on any retry.

@tarakanof tarakanof left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • xcodebuild Debug with CODE_SIGNING_ALLOWED=NO: the Swift code compiles. The only warnings left are the 2 Combine @State ones in DashboardView. The build then fails in the "Bundle & sign producer helpers" script, as expected here.
  • staleRefreshFromPreviousServerIsDropped pins the guard: with guard mine == generation removed, it fails at AppModelTests.swift:109.

Checked and fine:

  • ProducerInstallService: Sendable. Both boundary protocols are already Sendable and fileExists is @Sendable, so the conformance holds without @unchecked. The @concurrent batch ops and snapshot() are awaited from MainActor callers (the SwiftUI view and the Task in AppEnvironment inherit MainActor), so the @State and UserDefaults writes after each await run back on main.
  • Pipe fix. Draining stderr on another thread while stdout is read on the calling one removes the deadlock. DispatchGroup.wait() orders the DataBox write before the read.
  • AppModel poll loop. guard let self releases the model between iterations, so the loop ends once the model is gone.

Should-fix

  1. Retry can ring a reminder twice. This is likely with the current clock link. POST /v1/reminders/fire holds the response until publisher.Notify finishes, which can take up to 10 s (cmd/ember/reminders.go:67). The app session uses timeoutIntervalForRequest = 5 and timeoutIntervalForResource = 10 (APIClient.swift:82-83). So a slow push to the clock can land on the clock while the app sees a timeout. fire then 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 with hold the 15-min ack window is re-armed. The same happens when the clock shows the popup but its ack is lost, so Notify errors 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 before Notify. 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.

  2. Fallback place name changed from state to country. MKAddressRepresentations.regionName is the country ("United States" in the SDK header), not the old administrativeArea ("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

  1. Concurrent poll can double-fire. The ledger is now written after the await, so two concurrent poll() calls could both pass fired.contains. Today only one loop exists. The narrow case: disable then re-enable Reminders while a fire is in flight. The old loop's poll keeps running after cancel(), and the new loop can fire the same key. An in-flight set, marked before the await and cleared on failure, closes it.
  2. ProducersToggleSection briefly shows the empty state. Until the first snapshot() returns, the view renders .empty: toggle off, "No supported agent CLI detected…", and the toggle is enabled. Consider snapshot: ProducerSnapshot? with a disabled or placeholder state. Also, a late .task snapshot can overwrite the one apply just set. This is rare.
  3. Blocking work on the cooperative pool. The @concurrent ops call the synchronous runner and SMAppService on a cooperative-pool thread, and readDataToEndOfFile, waitUntilExit and DispatchGroup.wait block it. That's acceptable for a handful of agents, but the doc comment could say that the calls block.
  4. StubURLProtocol calls client from a global queue. Its callbacks now run off the thread that called startLoading, which breaks the URLProtocol contract. It works today, but it's a possible source of flaky tests. Only the stale-refresh test needs it.
  5. No test for the watcher's record-only-on-success rule. ReminderWatcher is 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant