Skip to content

perf(ios): coalesce strap-log publishes instead of one per line - #2548

Merged
ryanbr merged 2 commits into
mainfrom
fix/2547-coalesce-log-publish
Sep 28, 2026
Merged

ryanbr merged 2 commits into
mainfrom
fix/2547-coalesce-log-publish

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 28, 2026

Copy link
Copy Markdown
Owner

Summary

Addresses #2547. LiveState carries dozens of @Published properties on one ObservableObject, and an ObservableObject invalidates every observer on any published change. So @Published var log woke every view in the app once per appended line, and a history drain plus a re-score burst emits hundreds a minute.

That is the invalidation half of the background CPU kill @pipiche38 reported in #2521. #2546 cut the cost of each invalidation by about two orders of magnitude; this cuts how many there are, to at most four a second however fast lines arrive.

Android has throttled this for a while, via boundedRevision(ble.logRevision, coalesceMs = 250) in TestCentreScreen.kt. Same interval here, so the platforms behave alike rather than iOS being the outlier.

The rule: coalesce the publish, never the capture

log becomes a synchronous computed read over a non-published logBuffer, and a new @Published logRevision is what SwiftUI observes. Reading log therefore still returns the buffer as it is right now, so code that appends and then inspects the log in the same turn sees its own line. That is not hypothetical: AlarmReadbackDecodeTests, BatteryEstimateTraceEmitTests and others assert live.log.contains { ... } immediately after triggering an append, and coalescing the data rather than the notification would have broken them and lost real log lines.

The disk archive and the #990 drained-rows tally stay strictly per line, and the archive write happens before the publish.

Leading edge plus a trailing flush: the first line after an idle gap publishes immediately, so a live log does not sit a quarter second behind, and the last line of a burst always lands rather than waiting for a line that may never arrive.

Why the decision is a separate pure function

logPublishDecision(now:lastPublish:flushQueued:interval:) is a nonisolated static func returning an enum, so the whole policy is pinned by synchronous tests with no clock and no run loop, and the async half is a thin adapter that makes no decisions. The boundary case matters and is covered: elapsed equal to the interval publishes rather than scheduling, otherwise a steady stream at exactly the interval would queue a flush per line and double the work.

Two details that would bite a later edit

The try? on the sleep is not incidental. logFlushQueued is what suppresses every other publish in the window, so the one thing that closure must always do is clear it. A cancelled sleep throws; swallowing it and falling through still clears the flag and publishes. Rewriting it as try await with the error propagating would leave the flag set forever, and the log would keep capturing while the UI silently froze. There is a comment saying so at the site.

Isolation. A static member of a @MainActor type is actor-isolated, so the interval constant is nonisolated in order to be usable as the default argument of the nonisolated decision function. That is the same reason legacyTailKey in the same file carries the keyword, and the same mistake that cost #2546 a CI round trip.

Also

The card now observes logRevision rather than log.count. The revision is monotonic and ticks exactly once per publish; the count plateaus while the ring trims, so it could stay equal across an append and skip a scroll.

Verification

  • swiftc -parse and python3 Tools/doc_comment_lint.py: clean. Stated plainly because -parse does not type-check or check isolation, so on a Linux host it is close to no verification for Swift; the macOS leg is the real check and I audited the new symbols for isolation reachability by hand instead.
  • 9 new StrandTests cases. Seven pin the pure policy including the boundary, a backward clock, and the never-published sentinel. Two pin the behaviour that matters: that all fifty lines of a burst are readable synchronously, and that those fifty produce one publish rather than fifty. Reverting to a per-line publish turns the second into fifty.
  • Surveyed every reader and writer before changing the property: no $log publisher subscribers anywhere, the only writers are inside append(log:), and nothing ever assigned to log, so making it get-only is safe.
  • Strand/BLE/** is outside the parity ledger's SWIFT_GLOBS, so no parity entry is expected for the new symbols.

Not done here: moving log off LiveState entirely, the other option in #2547. That would remove the invalidation rather than rate-limit it, but it touches every reader and is a larger change; this one is bounded and matches a shipped Android precedent.

`LiveState` carries dozens of `@Published` properties on one `ObservableObject`, and an
`ObservableObject` invalidates EVERY observer on ANY published change. So `@Published var log` woke every
view in the app once per appended line, and a history drain plus a re-score burst emits hundreds a
minute. That was the invalidation half of the background CPU kill in #2521; #2546 cut the cost of each
invalidation, this cuts how many there are to at most four a second.

The Android twin has throttled this for a while, with `boundedRevision(ble.logRevision, coalesceMs =
250)` in TestCentreScreen.kt. Same interval here, so the two platforms behave alike.

Coalesces the PUBLISH, never the capture. `log` becomes a synchronous computed read of a non-published
buffer, so code that appends and then inspects the log in the same turn still sees its own line, which
many tests do. The disk archive and the #990 drained-rows tally stay per line and run before the publish.

Leading edge plus a trailing flush, so the first line after an idle gap is immediate and the last line of
a burst always lands rather than waiting for a line that may never come.

The decision is a pure `nonisolated static func` returning an enum, so the policy is pinned without a
clock or a run loop, and the async half is a thin adapter with no decisions of its own.

Two isolation details, because a `static` member of a `@MainActor` type is actor-isolated: the interval
constant is `nonisolated` so the nonisolated decision function can use it as a default argument, matching
`legacyTailKey` in the same file.

The card now observes `logRevision` rather than `log.count`. The revision is monotonic and ticks once per
publish; the count plateaus while the ring trims, so it could stay equal across an append and skip a
scroll.
Re-review of the previous commit. Two problems, one of them an implementation flaw.

A `lastPublish` in the future makes `interval - elapsed` EXCEED the interval, so a clock that went
backwards would stall the log un-notified for longer than one window (0.45s for a 0.20s backward step).
The wait is now clamped into [0, interval] at both ends, not just at zero.

And two tests compared doubles for equality through the enum's synthesized Equatable. The payload is the
result of a floating-point subtraction: `0.25 - (100.10 - 100)` is 0.15000000000000568, so asserting
`.scheduleIn(0.15)` compared two unequal values and would have failed on the macOS leg. Those now
destructure and assert with an accuracy, and the enum's own doc warns about the trap so the next test
written against it does the same.

The boundary case now uses exactly-representable values (1.0, 1.25, 0.25) instead of 100 and 100.25. It
passed before, but only because that particular subtraction rounded favourably, which is not what the
test is meant to be about.

Every case re-checked against the clamped arithmetic before pushing.
@ryanbr
ryanbr merged commit f4e8142 into main Sep 28, 2026
5 checks passed
@ryanbr
ryanbr deleted the fix/2547-coalesce-log-publish branch September 28, 2026 05:10
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