Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 49 additions & 2 deletions packages/platform-apple/src/snapshot-observability.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { expect, test, vi } from 'vitest';
import {
countDiagnosticEventsByPhase,
withDiagnosticsScope,
} from '@agent-device/host-kit/diagnostics';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { createLaunchObservationProbe } from './snapshot-observability.ts';
import type { SnapshotSourceFailure, SnapshotSourceOutcome } from './snapshot-source-facade.ts';
import type { SimulatorSnapshotTarget } from './snapshot-target.ts';

const simulator = {
platform: 'apple',
Expand Down Expand Up @@ -42,16 +47,19 @@ const acquired = (): SnapshotSourceOutcome => ({
function probe(
outcomes: readonly SnapshotSourceOutcome[],
clock: { now(): number; sleep(ms: number): Promise<void> },
isBridgeDisabled: (probed: SimulatorSnapshotTarget) => boolean = () => false,
) {
let index = 0;
const acquire = vi.fn(async () => outcomes[Math.min(index++, outcomes.length - 1)]!);
const sleep = vi.fn(clock.sleep);
const gate = vi.fn(isBridgeDisabled);
const observe = createLaunchObservationProbe({
source: { acquire, close: async () => {} },
resolveTarget: async () => target,
clock: { now: clock.now, sleep },
isBridgeDisabled: gate,
});
return { observe, acquire, sleep };
return { observe, acquire, sleep, gate };
}

test('a launched app is observable as soon as the bridge publishes it', async () => {
Expand Down Expand Up @@ -138,15 +146,54 @@ test('a failure outside the launch transition ends the wait at once', async () =
expect(sleep).not.toHaveBeenCalled();
});

test('a generation whose bridge circuit is open is unobservable without a bridge round trip', async () => {
const { observe, acquire, sleep, gate } = probe(
[acquired()],
{ now: () => 0, sleep: async () => {} },
() => true,
);
await expect(observe.awaitObservable(simulator, 'com.example.app', signal())).resolves.toBe(
'unobservable',
);
expect(gate).toHaveBeenCalledWith(target);
expect(acquire).not.toHaveBeenCalled();
expect(sleep).not.toHaveBeenCalled();
});

test('the skip is reported, so a live run can tell it from an unresolvable target', async () => {
// Both verdicts are `unobservable` with zero acquisitions; only the diagnostic separates a
// circuit skip from a target that never resolved.
await withDiagnosticsScope({ command: 'open' }, async () => {
const skipped = probe([acquired()], { now: () => 0, sleep: async () => {} }, () => true);
await skipped.observe.awaitObservable(simulator, 'com.example.app', signal());
expect(countDiagnosticEventsByPhase(['ios_launch_observation_skipped'])).toBe(1);
});
await withDiagnosticsScope({ command: 'open' }, async () => {
const unresolvable = createLaunchObservationProbe({
source: { acquire: vi.fn(), close: async () => {} },
resolveTarget: async () => {
throw new Error('no target');
},
clock: { now: () => 0, sleep: async () => {} },
isBridgeDisabled: () => true,
});
await expect(
unresolvable.awaitObservable(simulator, 'com.example.app', signal()),
).resolves.toBe('unobservable');
expect(countDiagnosticEventsByPhase(['ios_launch_observation_skipped'])).toBe(0);
});
});

test.each([
['a physical iOS device', { ...simulator, kind: 'device' as const }],
['a tvOS Simulator', { ...simulator, appleOs: 'tvos' as const, target: 'tv' as const }],
])('%s has no bridge and is not eligible', async (_name, device) => {
const { observe, acquire } = probe([acquired()], { now: () => 0, sleep: async () => {} });
const { observe, acquire, gate } = probe([acquired()], { now: () => 0, sleep: async () => {} });
await expect(observe.awaitObservable(device, 'com.example.app', signal())).resolves.toBe(
'not-eligible',
);
expect(acquire).not.toHaveBeenCalled();
expect(gate).not.toHaveBeenCalled();
});

function signal(): AbortSignal {
Expand Down
24 changes: 23 additions & 1 deletion packages/platform-apple/src/snapshot-observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@ import {
createIosSnapshotRequest,
deriveIosCaptureHint,
} from '@agent-device/capture-kit/ios-snapshot-planning';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
import type { DeviceInfo } from '@agent-device/kernel/device';
import type { SimulatorSnapshotSource } from './snapshot-source-facade.ts';
import type { SimulatorSnapshotTargetResolver } from './snapshot-target.ts';
import type {
SimulatorSnapshotTarget,
SimulatorSnapshotTargetResolver,
} from './snapshot-target.ts';

/**
* What an `open` learned about the app it just launched on a local Simulator: `observable` means
Expand Down Expand Up @@ -51,6 +55,7 @@ export function createLaunchObservationProbe(
source: SimulatorSnapshotSource;
resolveTarget: SimulatorSnapshotTargetResolver;
clock: PlatformRuntimeHost['clock'];
isBridgeDisabled: (target: SimulatorSnapshotTarget) => boolean;
}>,
): LaunchObservationPort {
const hint = deriveIosCaptureHint(createIosSnapshotRequest({ depth: 1, interactiveOnly: true }));
Expand All @@ -62,6 +67,23 @@ export function createLaunchObservationProbe(
const target = await deps.resolveTarget(device, appBundleId, signal).catch(() => undefined);
signal.throwIfAborted();
if (!target) return 'unobservable';
// A generation whose bridge already failed a capture fails this probe the same way, and
// the codes it fails with are the ones this loop re-reads for seconds. Ask the circuit
// first; a relaunch carries a new generation, which rebaselines and observes as usual.
// A skip is reported, because an unresolvable target reaches the same verdict by a
// different route and only the diagnostic tells the two apart on a live device.
if (deps.isBridgeDisabled(target)) {
emitDiagnostic({
level: 'debug',
phase: 'ios_launch_observation_skipped',
data: {
reason: 'circuit-disabled',
deviceId: device.id,
generation: target.generation,
},
});
return 'unobservable';
}
const outcome = await deps.source.acquire({ target, hint, signal });
if (outcome.stage !== 'failed') return 'observable';
signal.throwIfAborted();
Expand Down
58 changes: 58 additions & 0 deletions packages/platform-apple/src/snapshot-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,64 @@ test('a slow app discovery yields to a live runner within its wait slice, then s
}
});

test('an open whose generation already failed the bridge skips the launch-observation poll', async () => {
// #2199: `application-server-unavailable` is a launch-transition code, so an ungated probe would
// re-read the bridge every 150 ms for its whole 5 s window on a generation the circuit already
// gave up on — ~33 acquisitions per `open`, each a fresh connect.
const source = sourceReturning({
stage: 'failed',
failure: { kind: 'transport-failure', code: 'application-server-unavailable' },
});
const route = createAppleSnapshotRoute(
{ ...platformRuntimeHostFixture(), clock: steppingClock() },
{ source, resolveTarget: vi.fn(async () => target) },
);

await route.capture(ios, input, signal(), async () => runnerResult());
expect(source.acquire).toHaveBeenCalledOnce();

await expect(route.awaitObservable(ios, input.options.appBundleId, signal())).resolves.toBe(
'unobservable',
);
expect(source.acquire).toHaveBeenCalledOnce();
});

test('a relaunched generation rebaselines the circuit and observes the launch', async () => {
const outcomes: SnapshotSourceOutcome[] = [
{ stage: 'failed', failure: { kind: 'transport-failure', code: 'bridge-disconnected' } },
bridgeAcquisition(),
];
let acquisitions = 0;
const source = {
acquire: vi.fn(async () => outcomes[Math.min(acquisitions++, outcomes.length - 1)]!),
close: vi.fn(async () => {}),
};
const relaunched = { ...target, pid: 84, generation: '84:launch-b' };
const resolveTarget = vi.fn().mockResolvedValueOnce(target).mockResolvedValue(relaunched);
const route = createAppleSnapshotRoute(
{ ...platformRuntimeHostFixture(), clock: steppingClock() },
{ source, resolveTarget },
);

await route.capture(ios, input, signal(), async () => runnerResult());

await expect(route.awaitObservable(ios, input.options.appBundleId, signal())).resolves.toBe(
'observable',
);
expect(source.acquire).toHaveBeenCalledTimes(2);
});

/** A clock the launch-observation loop can run to its deadline instead of spinning forever. */
function steppingClock() {
let now = 0;
return {
now: () => now,
sleep: async (ms: number) => {
now += ms;
},
};
}

function bridgeAcquisition(): Extract<SnapshotSourceOutcome, { stage: 'acquired' }> {
return {
stage: 'acquired',
Expand Down
21 changes: 17 additions & 4 deletions packages/platform-apple/src/snapshot-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,22 @@ export function createAppleSnapshotRoute(
const resolveTarget = options.resolveTarget ?? createSimulatorSnapshotTargetResolver();
const disabledGenerations = new Set<string>();
const latestGeneration = new Map<string, string>();
const observation = createLaunchObservationProbe({ source, resolveTarget, clock: host.clock });
/**
* Records `target` as the newest generation of its app — which clears the circuit an earlier
* generation opened — and reports whether the bridge is disabled for it. Both a capture and the
* launch-observation probe ask before they spend a bridge round trip, so one generation's
* failure is paid once rather than once per route (#2198, #2199).
*/
const isBridgeDisabled = (target: SimulatorSnapshotTarget): boolean => {
rebaselineGeneration(target, latestGeneration, disabledGenerations);
return disabledGenerations.has(generationKey(target));
};
const observation = createLaunchObservationProbe({
source,
resolveTarget,
clock: host.clock,
isBridgeDisabled,
});

return Object.freeze({
awaitObservable: observation.awaitObservable,
Expand All @@ -79,9 +94,7 @@ export function createAppleSnapshotRoute(
[unknownGenerationResidue()],
);
}
rebaselineGeneration(target, latestGeneration, disabledGenerations);
const circuitKey = generationKey(target);
if (disabledGenerations.has(circuitKey)) {
if (isBridgeDisabled(target)) {
return await runFallback(input, fallback, target, requestFor(input), 'circuit-disabled');
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -604,18 +604,28 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector',
expect.objectContaining({ platform: 'apple', id: 'sim-1' }),
expect.objectContaining({
cleanStaleBundles: true,
buildTimeoutMs: 240000,
healthTimeoutMs: 240000,
logPath: expect.stringMatching(/runner\.log$/),
prepareDeadline: expect.objectContaining({
elapsedMs: expect.any(Function),
isExpired: expect.any(Function),
remainingMs: expect.any(Function),
}),
requestId: 'prepare-request',
startupTimeoutMs: 240000,
}),
);
// `prepareAppleRunner` spends one budget across the boot wait and the runner, so what reaches
// the runner is `--timeout` minus whatever readiness already used. Asserting the exact request
// asserts that zero wall-clock time passed, which is an accident of scheduling rather than a
// property of the system; the guarantee is that each budget is wired and never re-spent.
const [, prepareOptions] = mockPrepareIosRunner.mock.calls[0]!;
for (const field of ['buildTimeoutMs', 'healthTimeoutMs', 'startupTimeoutMs'] as const) {
expect
.soft(prepareOptions[field], `${field} carries the unspent remainder of --timeout`)
.toBeGreaterThan(239_000);
expect
.soft(prepareOptions[field], `${field} never exceeds --timeout`)
.toBeLessThanOrEqual(240_000);
}
if (response.ok) {
expect(response.data).toMatchObject({
action: 'ios-runner',
Expand Down
Loading