perf(apple): gate the launch-observation probe on the snapshot circuit breaker
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:
-
The ~5 s saving is not demonstrated live. It needs a generation whose bridge failure persists. I could not force one:
application-server-unavailableandapplication-element-missingdid 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. -
In the transient case the receipt captures, this PR is ~250 ms slower — step 3, 62 ms → 312 ms.
openitself restores foreground ownership, so onorigin/mainthe probe's first poll succeeded and skipped the fixed settle; here the probe declines and paysPOST_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 poll — expected "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.