Skip to content

perf(apple): gate the launch-observation probe on the snapshot circuit breaker - #2391

Merged
thymikee merged 3 commits into
mainfrom
claude/laughing-hermann-9c67d0
Sep 9, 2026
Merged

perf(apple): gate the launch-observation probe on the snapshot circuit breaker#2391
thymikee merged 3 commits into
mainfrom
claude/laughing-hermann-9c67d0

Conversation

@thymikee

@thymikee thymikee commented Sep 8, 2026

Copy link
Copy Markdown
Member

What

The Simulator AX-bridge launch-observation probe never consulted the generation circuit breaker that the snapshot capture route opens on a typed bridge failure. createAppleSnapshotRoute re-exported observation.awaitObservable straight through, and the probe's own loop had no way to ask.

Route and probe now share one isBridgeDisabled(target) predicate that rebaselines the generation and reads the circuit. The probe asks right after it resolves its target, so a generation the circuit already gave up on is answered without a bridge round trip.

Why

application-element-missing and application-server-unavailable each carry a 5 s launch-transition window at a 150 ms poll. Re-opening an app that is already running produces those codes on a generation whose circuit is already open, so such an open could spend up to ~33 source.acquire round trips learning what the circuit already knew — each retrying again inside connectUntilReady.

rebaselineGeneration only clears the circuit when the pid/label/start-time generation changes, and awaitObservable never called it, so nothing broke the loop.

Production path: settleAppleOpen (open-policy.ts) via lifecycle.ts.

Design note

The obvious shape — wrapping awaitObservable in the route so it resolves the target, gates, then delegates — was rejected. resolveTarget re-checks a cached target's identity with a ps spawn, so a route wrapper would charge every happy-path open an extra subprocess to speed up the unhappy path. Passing the predicate into the probe adds zero work when the circuit is closed.

Live Simulator receipt

Requested in review. iPhone 17 Pro (F7D6F9A4), iOS 26.2, com.apple.Preferences, built dist on this branch, one paired run against origin/main on the same simulator. The circuit is opened for real by stealing foreground accessibility ownership (simctl launch com.apple.MobileSMS) and then capturing, which the bridge rejects with foreground-owner-unverified.

Because a skipped probe and an unresolvable target both end at unobservable with zero acquisitions, 0 acquisitions alone proves nothing. Commit ea79a2d adds ios_launch_observation_skipped, and the receipt reads that diagnostic rather than inferring from counts.

Step origin/main this PR
1 — fresh launch, circuit closed 2 acq · observable · 3255 ms 0 acq · unobservable · 1814 ms
2 — capture → circuit opens foreground-owner-unverified on 88580:…Preferences[e45e] same
3 — re-open same generation 1 acq · observable · 62 ms 0 acq · skip diagnostic · 312 ms
4 — --relaunch, new generation 1 acq · observable · 1450 ms 1 acq · observable · 1501 ms

Step 3's diagnostic names the exact generation the circuit refused:

{"reason":"circuit-disabled","deviceId":"F7D6F9A4-…","generation":"88580:UIKitApplication:com.apple.Preferences[e45e][rb-legacy]:Wed Sep  9 10:48:54 2026"}

Step 4 carries no skip diagnostic and one acquisition, so the new generation rebaselined the circuit and observed normally.

What the receipt does not show, and one tradeoff worth your call

Two honest limits:

  1. The ~5 s saving is not demonstrated live. It needs a generation whose bridge failure persists. I could not force one: application-server-unavailable and application-element-missing did not reproduce on this simulator, and the three no-UI system apps I tried (AegirProxyApp, VoiceOverTouch, Bridge) all served the bridge fine. The 36 → 1 acquisition count is proven only by the unit test.

  2. In the transient case the receipt captures, this PR is ~250 ms slower — step 3, 62 ms → 312 ms. open itself restores foreground ownership, so on origin/main the probe's first poll succeeded and skipped the fixed settle; here the probe declines and pays POST_OPEN_SETTLE_MS.

That second point is the real tension: the circuit does not distinguish a transient failure from a persistent one, so gating the probe bets that an open circuit means a slow probe. For the motivating case that bet is right; for a self-healing failure it costs a quarter second. Worth noting that on origin/main the probe's observable verdict was not actionable anyway — the next capture for that generation still takes the XCTest fallback, so the round trip only bought the settle skip.

If you would rather not pay that, the natural follow-up is to skip the settle when the probe skipped (the capture is going to XCTest, which does its own readiness), which would make step 3 both 0-acquisition and ~60 ms. I did not do it here: it changes settle semantics beyond this PR's ask.

Reviewer notes

Both halves of the gate are mutation-proven, as is the diagnostic:

Mutation Test that catches it
Remove the probe's gate an open whose generation already failed the bridge skips the launch-observation pollexpected "vi.fn()" to be called once, but got 36 times
Gate without rebaselining a relaunched generation rebaselines the circuit and observes the launch, plus the pre-existing a new app generation re-enables the bridge
Drop the skip diagnostic the skip is reported, so a live run can tell it from an unresolvable target

The two route tests inject a stepping clock; the shared host fixture's clock is a frozen now: () => 10 with a no-op sleep, under which an ungated probe spins forever rather than failing.

CI

8b170ed fixes the Coverage failure you flagged. It is unrelated to this change and was a real flake, not noise: prepareAppleRunner spends one budget across the boot wait and the runner, so the runner receives --timeout minus elapsed time, and the test asserted exactly 240000 — true only when both Date.now() reads land in the same millisecond. CI observed 239999. It now asserts each budget carries the unspent remainder and never exceeds the request; forcing the remainder to 1 fails the lower bound and re-spending the full budget fails the upper.

Rebased onto e7d97f7. pnpm check:affected --run green: 285 files, 1904 tests, format/lint/typecheck clean.

Refs #2198, #2199.

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
Installed (including dependencies) 4.51 MB 4.51 MB +198 B
Package (unpacked) 4.51 MB 4.51 MB +198 B
Package (download) 1.34 MB 1.34 MB +67 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.1 ms 27.4 ms +0.3 ms
CLI --help 76.7 ms 76.7 ms -0.0 ms

@thymikee

thymikee commented Sep 8, 2026

Copy link
Copy Markdown
Member Author

The shared circuit check and generation reset look correct at a58bda9; no code findings. Before readiness, please add a live Simulator receipt showing a typed bridge failure opening the circuit, a same-generation open skipping the bridge poll, and relaunch restoring observation; generic smoke and mocked route tests do not exercise that sequence. Coverage also fails on the unchanged prewarm clock assertion, which appears unrelated to this change.

…t breaker

The Simulator AX-bridge launch-observation probe was re-exported straight
through `createAppleSnapshotRoute`, so it never consulted the generation
circuit the capture route opens on a typed bridge failure. Re-opening an
already-running app therefore re-polled a generation the circuit had already
given up on: `application-server-unavailable` and `application-element-missing`
carry a 5 s transition window at a 150 ms poll, so ~33 `source.acquire` round
trips per `open`, each reconnecting through `connectUntilReady`.

Route and probe now share one predicate that rebaselines the generation and
reports whether the bridge is disabled for it, so a disabled generation is
answered without a bridge round trip while a relaunch — which carries a new
generation — clears the circuit and observes as usual.

Refs #2198, #2199.
…ve it

A skipped probe and a target that never resolved both end the wait at
`unobservable` after zero bridge acquisitions, so on a live device the two are
indistinguishable — a live receipt could not show that the circuit gate, rather
than a resolution failure, is what stopped the poll.

Emit `ios_launch_observation_skipped` with the generation the circuit refused.
…passed

`prepareAppleRunner` spends one budget across the boot wait and the runner, so
what reaches the runner is `--timeout` minus whatever readiness already used.
The assertion demanded exactly 240000, which holds only when both `Date.now()`
reads land in the same millisecond; CI caught it at 239999.

Assert each budget carries the unspent remainder and never exceeds the request.
Both bounds are live: forcing the remainder to 1 fails the lower bound, and
re-spending the full budget fails the upper.
@thymikee
thymikee force-pushed the claude/laughing-hermann-9c67d0 branch from a58bda9 to 8b170ed Compare September 9, 2026 08:53
@thymikee

thymikee commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Live receipt added to the PR body, plus the CI fix. Three things, one of which you should probably rule on.

The receipt. iPhone 17 Pro / iOS 26.2, com.apple.Preferences, built dist on this branch, paired against origin/main on the same simulator. The circuit is opened for real: steal foreground accessibility ownership with simctl launch com.apple.MobileSMS, then capture, which the bridge rejects with foreground-owner-unverified.

While building it I hit a problem worth flagging: a skipped probe and a target that never resolved both end at unobservable after zero acquisitions, so 0 acquisitions proved nothing. ea79a2d adds ios_launch_observation_skipped, and the receipt reads that diagnostic instead of inferring from counts. Step 3 emits it naming the exact generation the circuit refused (88580:…Preferences[e45e]); step 4 emits none and acquires once, so the relaunched generation rebaselined and observed.

Two limits I could not close.

The ~5 s saving is not demonstrated live — it needs a generation whose bridge failure persists, and I could not force one. application-server-unavailable and application-element-missing did not reproduce on this simulator, and AegirProxyApp, VoiceOverTouch and Bridge all served the bridge fine. The 36 → 1 count is unit-test evidence only.

More importantly: in the one live case I could produce, this PR is ~250 ms slower. Step 3 goes 62 ms → 312 ms, because open itself restores foreground ownership, so on origin/main the probe's first poll succeeded and skipped the fixed settle, while here it declines and pays POST_OPEN_SETTLE_MS. The circuit does not distinguish a transient failure from a persistent one, so gating the probe bets that an open circuit means a slow probe — right for the motivating case, wrong by a quarter second for a self-healing one.

Mitigating it: on origin/main that observable verdict was not actionable anyway, since the next capture for that generation still takes the XCTest fallback. The round trip only bought the settle skip.

Your call. The clean follow-up is to skip the settle when the probe skipped — the capture is going to XCTest, which does its own readiness — making step 3 both zero-acquisition and ~60 ms, strictly better than either build. I did not do it here because it changes settle semantics beyond this PR's ask. Happy to add it here or file it, whichever you prefer.

CI. 8b170ed fixes Coverage. You were right that it is unrelated, and it is a real flake rather than noise: prepareAppleRunner spends one budget across the boot wait and the runner, so the runner gets --timeout minus elapsed time, and the test asserted exactly 240000 — true only when both Date.now() reads land in the same millisecond. CI saw 239999. It now asserts each budget carries the unspent remainder and never exceeds the request; both bounds are mutation-checked.

Rebased onto e7d97f7; pnpm check:affected --run green (285 files, 1904 tests).

@thymikee

thymikee commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

The shared circuit gate looks correct at 8b170ed, and the reported Simulator sequence now covers failure, same-generation skip and observation after relaunch. Checks are green. The remaining merge decision is the documented tradeoff: the transient case takes about 250 ms longer, while the avoided persistent-failure polling is demonstrated by tests, not a live timing result.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Sep 9, 2026
@thymikee
thymikee merged commit fda41b4 into main Sep 9, 2026
18 checks passed
@thymikee
thymikee deleted the claude/laughing-hermann-9c67d0 branch September 9, 2026 11:57
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-09 11:57 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant