Skip to content

Commit ae20958

Browse files
committed
perf(apple): gate the launch-observation probe on the snapshot circuit 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.
1 parent e7d97f7 commit ae20958

4 files changed

Lines changed: 105 additions & 7 deletions

File tree

‎packages/platform-apple/src/snapshot-observability.test.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { expect, test, vi } from 'vitest';
22
import type { DeviceInfo } from '@agent-device/kernel/device';
33
import { createLaunchObservationProbe } from './snapshot-observability.ts';
44
import type { SnapshotSourceFailure, SnapshotSourceOutcome } from './snapshot-source-facade.ts';
5+
import type { SimulatorSnapshotTarget } from './snapshot-target.ts';
56

67
const simulator = {
78
platform: 'apple',
@@ -42,16 +43,19 @@ const acquired = (): SnapshotSourceOutcome => ({
4243
function probe(
4344
outcomes: readonly SnapshotSourceOutcome[],
4445
clock: { now(): number; sleep(ms: number): Promise<void> },
46+
isBridgeDisabled: (probed: SimulatorSnapshotTarget) => boolean = () => false,
4547
) {
4648
let index = 0;
4749
const acquire = vi.fn(async () => outcomes[Math.min(index++, outcomes.length - 1)]!);
4850
const sleep = vi.fn(clock.sleep);
51+
const gate = vi.fn(isBridgeDisabled);
4952
const observe = createLaunchObservationProbe({
5053
source: { acquire, close: async () => {} },
5154
resolveTarget: async () => target,
5255
clock: { now: clock.now, sleep },
56+
isBridgeDisabled: gate,
5357
});
54-
return { observe, acquire, sleep };
58+
return { observe, acquire, sleep, gate };
5559
}
5660

5761
test('a launched app is observable as soon as the bridge publishes it', async () => {
@@ -138,15 +142,30 @@ test('a failure outside the launch transition ends the wait at once', async () =
138142
expect(sleep).not.toHaveBeenCalled();
139143
});
140144

145+
test('a generation whose bridge circuit is open is unobservable without a bridge round trip', async () => {
146+
const { observe, acquire, sleep, gate } = probe(
147+
[acquired()],
148+
{ now: () => 0, sleep: async () => {} },
149+
() => true,
150+
);
151+
await expect(observe.awaitObservable(simulator, 'com.example.app', signal())).resolves.toBe(
152+
'unobservable',
153+
);
154+
expect(gate).toHaveBeenCalledWith(target);
155+
expect(acquire).not.toHaveBeenCalled();
156+
expect(sleep).not.toHaveBeenCalled();
157+
});
158+
141159
test.each([
142160
['a physical iOS device', { ...simulator, kind: 'device' as const }],
143161
['a tvOS Simulator', { ...simulator, appleOs: 'tvos' as const, target: 'tv' as const }],
144162
])('%s has no bridge and is not eligible', async (_name, device) => {
145-
const { observe, acquire } = probe([acquired()], { now: () => 0, sleep: async () => {} });
163+
const { observe, acquire, gate } = probe([acquired()], { now: () => 0, sleep: async () => {} });
146164
await expect(observe.awaitObservable(device, 'com.example.app', signal())).resolves.toBe(
147165
'not-eligible',
148166
);
149167
expect(acquire).not.toHaveBeenCalled();
168+
expect(gate).not.toHaveBeenCalled();
150169
});
151170

152171
function signal(): AbortSignal {

‎packages/platform-apple/src/snapshot-observability.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
66
import type { DeviceInfo } from '@agent-device/kernel/device';
77
import type { SimulatorSnapshotSource } from './snapshot-source-facade.ts';
8-
import type { SimulatorSnapshotTargetResolver } from './snapshot-target.ts';
8+
import type {
9+
SimulatorSnapshotTarget,
10+
SimulatorSnapshotTargetResolver,
11+
} from './snapshot-target.ts';
912

1013
/**
1114
* What an `open` learned about the app it just launched on a local Simulator: `observable` means
@@ -51,6 +54,7 @@ export function createLaunchObservationProbe(
5154
source: SimulatorSnapshotSource;
5255
resolveTarget: SimulatorSnapshotTargetResolver;
5356
clock: PlatformRuntimeHost['clock'];
57+
isBridgeDisabled: (target: SimulatorSnapshotTarget) => boolean;
5458
}>,
5559
): LaunchObservationPort {
5660
const hint = deriveIosCaptureHint(createIosSnapshotRequest({ depth: 1, interactiveOnly: true }));
@@ -62,6 +66,10 @@ export function createLaunchObservationProbe(
6266
const target = await deps.resolveTarget(device, appBundleId, signal).catch(() => undefined);
6367
signal.throwIfAborted();
6468
if (!target) return 'unobservable';
69+
// A generation whose bridge already failed a capture fails this probe the same way, and
70+
// the codes it fails with are the ones this loop re-reads for seconds. Ask the circuit
71+
// first; a relaunch carries a new generation, which rebaselines and observes as usual.
72+
if (deps.isBridgeDisabled(target)) return 'unobservable';
6573
const outcome = await deps.source.acquire({ target, hint, signal });
6674
if (outcome.stage !== 'failed') return 'observable';
6775
signal.throwIfAborted();

‎packages/platform-apple/src/snapshot-route.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,64 @@ test('a slow app discovery yields to a live runner within its wait slice, then s
269269
}
270270
});
271271

272+
test('an open whose generation already failed the bridge skips the launch-observation poll', async () => {
273+
// #2199: `application-server-unavailable` is a launch-transition code, so an ungated probe would
274+
// re-read the bridge every 150 ms for its whole 5 s window on a generation the circuit already
275+
// gave up on — ~33 acquisitions per `open`, each a fresh connect.
276+
const source = sourceReturning({
277+
stage: 'failed',
278+
failure: { kind: 'transport-failure', code: 'application-server-unavailable' },
279+
});
280+
const route = createAppleSnapshotRoute(
281+
{ ...platformRuntimeHostFixture(), clock: steppingClock() },
282+
{ source, resolveTarget: vi.fn(async () => target) },
283+
);
284+
285+
await route.capture(ios, input, signal(), async () => runnerResult());
286+
expect(source.acquire).toHaveBeenCalledOnce();
287+
288+
await expect(route.awaitObservable(ios, input.options.appBundleId, signal())).resolves.toBe(
289+
'unobservable',
290+
);
291+
expect(source.acquire).toHaveBeenCalledOnce();
292+
});
293+
294+
test('a relaunched generation rebaselines the circuit and observes the launch', async () => {
295+
const outcomes: SnapshotSourceOutcome[] = [
296+
{ stage: 'failed', failure: { kind: 'transport-failure', code: 'bridge-disconnected' } },
297+
bridgeAcquisition(),
298+
];
299+
let acquisitions = 0;
300+
const source = {
301+
acquire: vi.fn(async () => outcomes[Math.min(acquisitions++, outcomes.length - 1)]!),
302+
close: vi.fn(async () => {}),
303+
};
304+
const relaunched = { ...target, pid: 84, generation: '84:launch-b' };
305+
const resolveTarget = vi.fn().mockResolvedValueOnce(target).mockResolvedValue(relaunched);
306+
const route = createAppleSnapshotRoute(
307+
{ ...platformRuntimeHostFixture(), clock: steppingClock() },
308+
{ source, resolveTarget },
309+
);
310+
311+
await route.capture(ios, input, signal(), async () => runnerResult());
312+
313+
await expect(route.awaitObservable(ios, input.options.appBundleId, signal())).resolves.toBe(
314+
'observable',
315+
);
316+
expect(source.acquire).toHaveBeenCalledTimes(2);
317+
});
318+
319+
/** A clock the launch-observation loop can run to its deadline instead of spinning forever. */
320+
function steppingClock() {
321+
let now = 0;
322+
return {
323+
now: () => now,
324+
sleep: async (ms: number) => {
325+
now += ms;
326+
},
327+
};
328+
}
329+
272330
function bridgeAcquisition(): Extract<SnapshotSourceOutcome, { stage: 'acquired' }> {
273331
return {
274332
stage: 'acquired',

‎packages/platform-apple/src/snapshot-route.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,22 @@ export function createAppleSnapshotRoute(
5757
const resolveTarget = options.resolveTarget ?? createSimulatorSnapshotTargetResolver();
5858
const disabledGenerations = new Set<string>();
5959
const latestGeneration = new Map<string, string>();
60-
const observation = createLaunchObservationProbe({ source, resolveTarget, clock: host.clock });
60+
/**
61+
* Records `target` as the newest generation of its app — which clears the circuit an earlier
62+
* generation opened — and reports whether the bridge is disabled for it. Both a capture and the
63+
* launch-observation probe ask before they spend a bridge round trip, so one generation's
64+
* failure is paid once rather than once per route (#2198, #2199).
65+
*/
66+
const isBridgeDisabled = (target: SimulatorSnapshotTarget): boolean => {
67+
rebaselineGeneration(target, latestGeneration, disabledGenerations);
68+
return disabledGenerations.has(generationKey(target));
69+
};
70+
const observation = createLaunchObservationProbe({
71+
source,
72+
resolveTarget,
73+
clock: host.clock,
74+
isBridgeDisabled,
75+
});
6176

6277
return Object.freeze({
6378
awaitObservable: observation.awaitObservable,
@@ -79,9 +94,7 @@ export function createAppleSnapshotRoute(
7994
[unknownGenerationResidue()],
8095
);
8196
}
82-
rebaselineGeneration(target, latestGeneration, disabledGenerations);
83-
const circuitKey = generationKey(target);
84-
if (disabledGenerations.has(circuitKey)) {
97+
if (isBridgeDisabled(target)) {
8598
return await runFallback(input, fallback, target, requestFor(input), 'circuit-disabled');
8699
}
87100

0 commit comments

Comments
 (0)