perf(apple): gate the launch-observation probe on the snapshot circuit breaker - #2391
Conversation
Size Report
Startup median (7 runs, lower is better):
|
|
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.
a58bda9 to
8b170ed
Compare
|
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, While building it I hit a problem worth flagging: a skipped probe and a target that never resolved both end at 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. More importantly: in the one live case I could produce, this PR is ~250 ms slower. Step 3 goes 62 ms → 312 ms, because Mitigating it: on 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. Rebased onto |
|
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. |
|
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.
createAppleSnapshotRoutere-exportedobservation.awaitObservablestraight 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-missingandapplication-server-unavailableeach 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 anopencould spend up to ~33source.acquireround trips learning what the circuit already knew — each retrying again insideconnectUntilReady.rebaselineGenerationonly clears the circuit when the pid/label/start-time generation changes, andawaitObservablenever called it, so nothing broke the loop.Production path:
settleAppleOpen(open-policy.ts) vialifecycle.ts.Design note
The obvious shape — wrapping
awaitObservablein the route so it resolves the target, gates, then delegates — was rejected.resolveTargetre-checks a cached target's identity with apsspawn, so a route wrapper would charge every happy-pathopenan 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, builtdiston this branch, one paired run againstorigin/mainon 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 withforeground-owner-unverified.Because a skipped probe and an unresolvable target both end at
unobservablewith zero acquisitions,0 acquisitionsalone proves nothing. Commitea79a2daddsios_launch_observation_skipped, and the receipt reads that diagnostic rather than inferring from counts.origin/mainobservable· 3255 msunobservable· 1814 msforeground-owner-unverifiedon88580:…Preferences[e45e]observable· 62 ms--relaunch, new generationobservable· 1450 msobservable· 1501 msStep 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/mainthe probe'sobservableverdict 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:
an open whose generation already failed the bridge skips the launch-observation poll—expected "vi.fn()" to be called once, but got 36 timesa relaunched generation rebaselines the circuit and observes the launch, plus the pre-existinga new app generation re-enables the bridgethe skip is reported, so a live run can tell it from an unresolvable targetThe two route tests inject a stepping clock; the shared host fixture's clock is a frozen
now: () => 10with a no-opsleep, under which an ungated probe spins forever rather than failing.CI
8b170edfixes the Coverage failure you flagged. It is unrelated to this change and was a real flake, not noise:prepareAppleRunnerspends one budget across the boot wait and the runner, so the runner receives--timeoutminus elapsed time, and the test asserted exactly240000— true only when bothDate.now()reads land in the same millisecond. CI observed239999. It now asserts each budget carries the unspent remainder and never exceeds the request; forcing the remainder to1fails the lower bound and re-spending the full budget fails the upper.Rebased onto
e7d97f7.pnpm check:affected --rungreen: 285 files, 1904 tests, format/lint/typecheck clean.Refs #2198, #2199.