Skip to content

Commit 6adfa7a

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/wait-poll-timeline
* origin/main: fix(ios): stop charging every capture for a slow Simulator app discovery (#2331) refactor(commands): move commands-side rendering out of src/daemon and retire the doctor progress flag (#2349) # Conflicts: # CHANGELOG.md
2 parents cc1add6 + 835af32 commit 6adfa7a

33 files changed

Lines changed: 610 additions & 161 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
runner-restart) next to the unchanged `reason`, so a failure says where its budget went without
99
opening the request log. Long waits keep the first five and last twenty-five polls. `wait
1010
--stable` timeouts and a never-readable strict absence keep their existing diagnostics.
11+
- Fixed: the iOS Simulator AX snapshot route bounds how long a capture waits for app discovery
12+
and stops starting a discovery per capture. Discovery (`simctl launchctl list` through xcrun)
13+
takes seconds on a loaded host; a capture now waits at most 1.5s for the one in-flight
14+
discovery, takes the XCTest fallback, and the discovery keeps running under its own 15s
15+
deadline for the captures that follow. Previously each capture ran its own probe with a 3s
16+
timeout on its critical path, so a `wait` issued right after `open` could spend its budget on
17+
probe timeouts and report `wait_capture_stalled` with the app already on screen.
1118
- Fixed: iOS snapshots no longer report `truncated: true` merely because a later backend produced
1219
them. The runner stamped every recovered capture as truncated — including a complete private-AX
1320
tree taken while the XCTest channel was penalized as slow — so a strict `is absent` / `wait absent`

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { expect, test, vi } from 'vitest';
22
import type { DeviceInfo } from '@agent-device/kernel/device';
33
import { areIosSnapshotComparisonIdentitiesEqual } from '@agent-device/capture-kit/ios-snapshot-planning';
4+
import { createLocalAppleToolProvider, withAppleToolProvider } from './core/tool-provider.ts';
45
import { platformRuntimeHostFixture } from './runtime.fixtures.ts';
56
import { createAppleSnapshotRoute } from './snapshot-route.ts';
7+
import { createSimulatorSnapshotTargetResolver } from './snapshot-target.ts';
68
import type { SimulatorSnapshotSource, SnapshotSourceOutcome } from './snapshot-source-facade.ts';
79

810
const ios = {
@@ -202,6 +204,68 @@ test('cancelled acquisition does not start a fallback after the request aborts',
202204
expect(fallback).not.toHaveBeenCalled();
203205
});
204206

207+
test('a slow app discovery yields to the XCTest fallback within its wait slice, then serves the bridge', async () => {
208+
// The production resolver over a simctl whose `launchctl list` answers only when released,
209+
// the shape of a loaded CI host: the first capture must not sit on that probe.
210+
let release!: () => void;
211+
const released = new Promise<void>((resolve) => {
212+
release = resolve;
213+
});
214+
const run = vi.fn(async (args: string[]) => {
215+
if (args[0] === 'spawn') await released;
216+
return {
217+
stdout:
218+
args[0] === 'spawn'
219+
? `42\t0\tUIKitApplication:${input.options.appBundleId}[launch-a][rb-legacy]`
220+
: JSON.stringify({
221+
devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [{ udid: ios.id }] },
222+
}),
223+
stderr: '',
224+
exitCode: 0,
225+
};
226+
});
227+
const runCommand = vi.fn(async () => ({ stdout: 'start-a', stderr: '', exitCode: 0 }));
228+
const fallback = vi.fn(async () => runnerResult());
229+
const source = sourceReturning(bridgeAcquisition());
230+
const presentIosAcquisition = vi.fn(async () => ({
231+
backend: 'xctest' as const,
232+
producer: 'simulator-ax-bridge' as const,
233+
nodes: [{ index: 0, type: 'Application' }],
234+
}));
235+
const route = createAppleSnapshotRoute(
236+
{
237+
...platformRuntimeHostFixture(),
238+
snapshot: { captureSurface: vi.fn(), presentIosAcquisition },
239+
},
240+
{ source, resolveTarget: createSimulatorSnapshotTargetResolver() },
241+
);
242+
vi.useFakeTimers();
243+
try {
244+
await withAppleToolProvider(
245+
createLocalAppleToolProvider({ simctl: { run }, runCommand }),
246+
async () => {
247+
const first = route.capture(ios, input, signal(), fallback);
248+
await vi.advanceTimersByTimeAsync(1_500);
249+
const result = await first;
250+
expect(fallback).toHaveBeenCalledOnce();
251+
expect(result.warnings).toEqual([
252+
'Simulator AX snapshot unavailable (target-resolution-failed); used XCTest for an unverified app generation.',
253+
]);
254+
expect(source.acquire).not.toHaveBeenCalled();
255+
256+
release();
257+
await vi.advanceTimersByTimeAsync(0);
258+
const second = await route.capture(ios, input, signal(), fallback);
259+
expect(second.producer).toBe('simulator-ax-bridge');
260+
expect(fallback).toHaveBeenCalledOnce();
261+
expect(run.mock.calls.filter(([args]) => args[0] === 'spawn')).toHaveLength(1);
262+
},
263+
);
264+
} finally {
265+
vi.useRealTimers();
266+
}
267+
});
268+
205269
function bridgeAcquisition(): Extract<SnapshotSourceOutcome, { stage: 'acquired' }> {
206270
return {
207271
stage: 'acquired',

packages/platform-apple/src/snapshot-target.test.ts

Lines changed: 132 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const signal = () => new AbortController().signal;
1717

1818
function targetFixture() {
1919
const state = { pid: 42, launch: 'launch-a', start: 'start-a' as string | null };
20-
const run = vi.fn(async (args: string[]) => ({
20+
const run = vi.fn(async (args: string[], _options?: { timeoutMs?: number }) => ({
2121
stdout:
2222
args[0] === 'spawn'
2323
? `90\t0\tUIKitApplication:com.example.app.beta[wrong][rb-legacy]\n${state.pid}\t0\tUIKitApplication:${app}[${state.launch}][rb-legacy]`
@@ -27,11 +27,13 @@ function targetFixture() {
2727
stderr: '',
2828
exitCode: 0,
2929
}));
30-
const runCommand = vi.fn(async () => ({
31-
stdout: state.start ?? '',
32-
stderr: '',
33-
exitCode: state.start ? 0 : 1,
34-
}));
30+
const runCommand = vi.fn(
31+
async (_tool: string, _args: string[], _options?: { timeoutMs?: number }) => ({
32+
stdout: state.start ?? '',
33+
stderr: '',
34+
exitCode: state.start ? 0 : 1,
35+
}),
36+
);
3537
const provider = createLocalAppleToolProvider({ simctl: { run }, runCommand });
3638
const resolve = createSimulatorSnapshotTargetResolver();
3739
return {
@@ -137,3 +139,127 @@ test('an aborted request cannot reuse a cached target', async () => {
137139
expect(fixture.runCommand).toHaveBeenCalledTimes(1);
138140
});
139141
});
142+
143+
function deferredSpawn(fixture: ReturnType<typeof targetFixture>) {
144+
let release!: () => void;
145+
const released = new Promise<void>((resolve) => {
146+
release = resolve;
147+
});
148+
const respond = fixture.run.getMockImplementation()!;
149+
fixture.run.mockImplementation(async (args: string[]) =>
150+
args[0] === 'spawn' ? await released.then(() => respond(args)) : await respond(args),
151+
);
152+
return release;
153+
}
154+
155+
test('a slow discovery yields to the fallback after its wait budget and finishes in the background', async () => {
156+
const fixture = targetFixture();
157+
const release = deferredSpawn(fixture);
158+
vi.useFakeTimers();
159+
try {
160+
await withAppleToolProvider(fixture.provider, async () => {
161+
const pending = fixture.resolve(ios, app, signal());
162+
const rejected = expect(pending).rejects.toMatchObject({
163+
details: { reason: 'simulator-target-discovery-pending' },
164+
});
165+
await vi.advanceTimersByTimeAsync(1_500);
166+
await rejected;
167+
expect(fixture.discoveryCount()).toBe(1);
168+
169+
release();
170+
await vi.advanceTimersByTimeAsync(0);
171+
// The finished discovery serves the next capture without a second simctl spawn.
172+
expect(await fixture.resolve(ios, app, signal())).toMatchObject({ pid: 42 });
173+
expect(fixture.discoveryCount()).toBe(1);
174+
});
175+
} finally {
176+
vi.useRealTimers();
177+
}
178+
});
179+
180+
test('captures that arrive during discovery join it instead of spawning their own', async () => {
181+
const fixture = targetFixture();
182+
const release = deferredSpawn(fixture);
183+
await withAppleToolProvider(fixture.provider, async () => {
184+
const first = fixture.resolve(ios, app, signal());
185+
const second = fixture.resolve(ios, app, signal());
186+
release();
187+
const targets = await Promise.all([first, second]);
188+
expect(targets[1]).toBe(targets[0]);
189+
expect(fixture.discoveryCount()).toBe(1);
190+
});
191+
});
192+
193+
test('a cancelled caller leaves discovery running for the next capture', async () => {
194+
const fixture = targetFixture();
195+
const release = deferredSpawn(fixture);
196+
await withAppleToolProvider(fixture.provider, async () => {
197+
const controller = new AbortController();
198+
const cancelled = fixture.resolve(ios, app, controller.signal);
199+
controller.abort(new Error('request-ended'));
200+
await expect(cancelled).rejects.toThrow('request-ended');
201+
202+
release();
203+
expect(await fixture.resolve(ios, app, signal())).toMatchObject({ pid: 42 });
204+
expect(fixture.discoveryCount()).toBe(1);
205+
});
206+
});
207+
208+
test('one discovery shares a single deadline across its simctl probes and the identity read', async () => {
209+
const fixture = targetFixture();
210+
const release = deferredSpawn(fixture);
211+
vi.useFakeTimers();
212+
try {
213+
await withAppleToolProvider(fixture.provider, async () => {
214+
const pending = fixture.resolve(ios, app, signal());
215+
pending.catch(() => undefined);
216+
// The spawn ran 13s of the 15s discovery deadline before answering.
217+
await vi.advanceTimersByTimeAsync(13_000);
218+
release();
219+
await vi.advanceTimersByTimeAsync(0);
220+
await expect(fixture.resolve(ios, app, signal())).resolves.toMatchObject({ pid: 42 });
221+
222+
const spawnOptions = fixture.run.mock.calls.find(([args]) => args[0] === 'spawn')?.[1];
223+
expect(spawnOptions?.timeoutMs).toBe(15_000);
224+
const identityOptions = fixture.runCommand.mock.calls[0]?.[2];
225+
expect(identityOptions?.timeoutMs).toBeGreaterThan(0);
226+
expect(identityOptions?.timeoutMs).toBeLessThanOrEqual(2_000);
227+
});
228+
} finally {
229+
vi.useRealTimers();
230+
}
231+
});
232+
233+
test('a failed runtime probe does not release the slot while the launch-job probe still runs', async () => {
234+
const fixture = targetFixture();
235+
const release = deferredSpawn(fixture);
236+
const respond = fixture.run.getMockImplementation()!;
237+
fixture.run.mockImplementation(async (args: string[], options) =>
238+
args[0] === 'list'
239+
? { stdout: '', stderr: 'simctl list failed', exitCode: 1 }
240+
: await respond(args, options),
241+
);
242+
vi.useFakeTimers();
243+
try {
244+
await withAppleToolProvider(fixture.provider, async () => {
245+
for (let attempt = 0; attempt < 3; attempt += 1) {
246+
const pending = fixture.resolve(ios, app, signal());
247+
const rejected = expect(pending).rejects.toMatchObject({
248+
details: { reason: 'simulator-target-discovery-pending' },
249+
});
250+
await vi.advanceTimersByTimeAsync(1_500);
251+
await rejected;
252+
}
253+
expect(fixture.discoveryCount()).toBe(1);
254+
255+
release();
256+
await vi.advanceTimersByTimeAsync(0);
257+
// The settled discovery reports the runtime failure and frees the slot for a fresh probe.
258+
await expect(fixture.resolve(ios, app, signal())).rejects.toMatchObject({
259+
details: { reason: 'simulator-runtime-probe-failed' },
260+
});
261+
});
262+
} finally {
263+
vi.useRealTimers();
264+
}
265+
});

0 commit comments

Comments
 (0)