Skip to content

Commit 3afd154

Browse files
authored
perf: retain iOS runner across physical relaunch (#2200)
1 parent 010f09b commit 3afd154

4 files changed

Lines changed: 242 additions & 32 deletions

File tree

docs/adr/0005-ios-runner-interaction-lifecycle.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,12 @@ root-only payload instead of issuing more flat fallback queries against the same
6262
the cached `XCUIApplication` handle is cleared so the next command reacquires the target through the
6363
normal activation path.
6464

65-
An external iOS simulator relaunch also invalidates process-bound target state. After replacing the
66-
app process, the daemon sends a lifecycle reset to the retained runner so the next command reacquires
67-
`XCUIApplication`; the reset also clears process-bound snapshot penalty and private-AX depth state. If
68-
that reset cannot be confirmed, the daemon discards the runner session.
65+
An external iOS-family Simulator relaunch or physical iOS relaunch also invalidates process-bound
66+
target state. After replacing the app process, the daemon sends a lifecycle reset to the retained
67+
runner so the next command reacquires `XCUIApplication`; the reset also clears process-bound snapshot
68+
penalty and private-AX depth state. If that reset cannot be confirmed, or if relaunch fails before
69+
reset, the daemon discards the runner session. Other physical Apple OS leaves retain their existing
70+
runner-restart behavior until separately evidenced.
6971

7072
The snapshot surface intentionally has two AX-failure shapes. Interactive fast snapshots return a
7173
truncated success payload with `runnerFatal` so agents can still see that AX state is unavailable
@@ -100,9 +102,10 @@ capture. A changed accessibility digest is reported as success with an explicit
100102
not blindly repeat a tap that may already have navigated; unchanged, sparse, mismatched, or unavailable
101103
evidence remains the original failure.
102104

103-
Simulator relaunch keeps the healthy XCTest process warm without carrying an app target across
104-
process identity. The reset adds one local runner request instead of paying for a runner restart and
105-
clears the old process's hostile-screen capture penalty before the replacement is reacquired.
105+
iOS-family Simulator and physical iOS relaunch keep the healthy XCTest process warm without carrying
106+
an app target across process identity. The reset adds one local runner request instead of paying for a
107+
runner restart and clears the old process's hostile-screen capture penalty before the replacement is
108+
reacquired.
106109

107110
Future optimization work should only reduce these preflights after the runner exposes status in a
108111
way that survives command-induced XCTest teardown and can prove the session is still serving new
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { expect, test, vi } from 'vitest';
2+
import type { OpenApplicationInput } from '@agent-device/contracts/application-lifecycle-runtime';
3+
import type { Interactor } from '@agent-device/contracts/interactor-types';
4+
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
5+
import type { DeviceInfo } from '@agent-device/kernel/device';
6+
import { bindAppleApplicationLifecycle } from './lifecycle.ts';
7+
import { platformRuntimeHostFixture } from './runtime.fixtures.ts';
8+
9+
const device: DeviceInfo = {
10+
platform: 'apple',
11+
appleOs: 'ios',
12+
id: 'ios-device',
13+
name: 'iPhone',
14+
kind: 'device',
15+
target: 'mobile',
16+
booted: true,
17+
iosPhysicalDeviceBackend: 'coredevice',
18+
};
19+
20+
test.each(['coredevice', 'xctest'] as const)(
21+
'retains a physical iOS runner through relaunch and resets its target with the %s backend',
22+
async (iosPhysicalDeviceBackend) => {
23+
const selectedDevice = { ...device, iosPhysicalDeviceBackend };
24+
const signal = new AbortController().signal;
25+
const events: string[] = [];
26+
const interactor = {
27+
close: vi.fn(async () => {
28+
events.push('close');
29+
}),
30+
open: vi.fn(async () => {
31+
events.push('open');
32+
}),
33+
} as unknown as Interactor;
34+
const baseHost = platformRuntimeHostFixture();
35+
const stopRunnerSession = vi.fn(async () => {
36+
events.push('stop');
37+
});
38+
const prewarmRunnerSession = vi.fn(async () => {
39+
events.push('prewarm');
40+
});
41+
const notifyRunnerAppRelaunched = vi.fn(async () => {
42+
events.push('reset');
43+
});
44+
const host = {
45+
...baseHost,
46+
localInteractors: { resolve: async () => interactor },
47+
appleApplications: {
48+
...baseHost.appleApplications,
49+
stopRunnerSession,
50+
prewarmRunnerSession,
51+
notifyRunnerAppRelaunched,
52+
},
53+
} as unknown as PlatformRuntimeHost;
54+
const lifecycle = bindAppleApplicationLifecycle({ host, device: selectedDevice, signal });
55+
56+
await lifecycle.openApplication(openInput());
57+
58+
expect(events).toEqual(['close', 'open', 'prewarm', 'reset']);
59+
expect(stopRunnerSession).not.toHaveBeenCalled();
60+
expect(notifyRunnerAppRelaunched).toHaveBeenCalledWith(selectedDevice, {}, signal);
61+
},
62+
);
63+
64+
test.each(['ipados', 'tvos', 'visionos'] as const)(
65+
'preserves runner restart semantics for a physical %s target',
66+
async (appleOs) => {
67+
const selectedDevice = { ...device, appleOs };
68+
const signal = new AbortController().signal;
69+
const events: string[] = [];
70+
const interactor = {
71+
close: vi.fn(async () => {
72+
events.push('close');
73+
}),
74+
open: vi.fn(async () => {
75+
events.push('open');
76+
}),
77+
} as unknown as Interactor;
78+
const baseHost = platformRuntimeHostFixture();
79+
const stopRunnerSession = vi.fn(async () => {
80+
events.push('stop');
81+
});
82+
const prewarmRunnerSession = vi.fn(async () => {
83+
events.push('prewarm');
84+
});
85+
const notifyRunnerAppRelaunched = vi.fn(async () => {
86+
events.push('reset');
87+
});
88+
const host = {
89+
...baseHost,
90+
localInteractors: { resolve: async () => interactor },
91+
appleApplications: {
92+
...baseHost.appleApplications,
93+
stopRunnerSession,
94+
prewarmRunnerSession,
95+
notifyRunnerAppRelaunched,
96+
},
97+
} as unknown as PlatformRuntimeHost;
98+
const lifecycle = bindAppleApplicationLifecycle({ host, device: selectedDevice, signal });
99+
100+
await lifecycle.openApplication(openInput());
101+
102+
expect(events).toEqual(['stop', 'close', 'open', 'prewarm']);
103+
expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled();
104+
},
105+
);
106+
107+
test('discards a retained physical iOS runner when relaunch fails and preserves the failure', async () => {
108+
const signal = new AbortController().signal;
109+
const events: string[] = [];
110+
const relaunchFailure = new Error('app failed to reopen');
111+
const interactor = {
112+
close: vi.fn(async () => {
113+
events.push('close');
114+
}),
115+
open: vi.fn(async () => {
116+
events.push('open');
117+
throw relaunchFailure;
118+
}),
119+
} as unknown as Interactor;
120+
const baseHost = platformRuntimeHostFixture();
121+
const stopRunnerSession = vi.fn(async () => {
122+
events.push('stop');
123+
throw new Error('runner cleanup failed');
124+
});
125+
const notifyRunnerAppRelaunched = vi.fn(async () => {
126+
events.push('reset');
127+
});
128+
const host = {
129+
...baseHost,
130+
localInteractors: { resolve: async () => interactor },
131+
appleApplications: {
132+
...baseHost.appleApplications,
133+
stopRunnerSession,
134+
notifyRunnerAppRelaunched,
135+
},
136+
} as unknown as PlatformRuntimeHost;
137+
const lifecycle = bindAppleApplicationLifecycle({ host, device, signal });
138+
139+
await expect(lifecycle.openApplication(openInput())).rejects.toBe(relaunchFailure);
140+
141+
expect(events).toEqual(['close', 'open', 'stop']);
142+
expect(stopRunnerSession).toHaveBeenCalledWith(device.id);
143+
expect(notifyRunnerAppRelaunched).not.toHaveBeenCalled();
144+
});
145+
146+
function openInput(): OpenApplicationInput {
147+
return {
148+
target: 'com.example.app',
149+
positionals: ['com.example.app'],
150+
appBundleId: 'com.example.app',
151+
surface: 'app',
152+
hasExistingSession: true,
153+
relaunch: true,
154+
prewarmRunnerBeforeOpen: false,
155+
enableTestIme: false,
156+
stateDir: '/tmp/agent-device-lifecycle-test',
157+
runtimeHints: {},
158+
execution: {},
159+
};
160+
}

packages/platform-apple/src/lifecycle.ts

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -96,34 +96,55 @@ async function openAppleApplication(
9696
input.surface === 'app' &&
9797
input.positionals.length > 0 &&
9898
Boolean(input.appBundleId);
99-
if (localIosSimulator && shouldPrewarmRunner && !input.prewarmRunnerBeforeOpen) runner.schedule();
100-
await closeAppleApplicationForRelaunch(host, binding, input, localIosSimulator, timing);
101-
await applyAppleOpenRuntimeHints(input, timing);
102-
await prewarmAppleRunnerBeforeOpen(runner, shouldPrewarmRunner, input.prewarmRunnerBeforeOpen);
103-
const runnerTargetPredatesOpen = runner.wasAwaited();
104-
await dispatchAppleOpen(binding, input, localIosSimulator, timing);
105-
await finishAppleRunnerPrewarm(runner, shouldPrewarmRunner, input.relaunch);
106-
await notifyAppleRunnerRelaunch(
107-
host,
108-
binding,
99+
const retainRunnerForRelaunch = shouldRetainRunnerForRelaunch(
100+
binding.device,
109101
input,
110102
localIosSimulator,
111-
runnerTargetPredatesOpen,
112103
);
113-
await settleAppleOpen(host, binding, localIosSimulator, timing);
114-
return { appBundleId: input.appBundleId, timing };
104+
if (localIosSimulator && shouldPrewarmRunner && !input.prewarmRunnerBeforeOpen) runner.schedule();
105+
try {
106+
await closeAppleApplicationForRelaunch(
107+
host,
108+
binding,
109+
input,
110+
localIosSimulator,
111+
retainRunnerForRelaunch,
112+
timing,
113+
);
114+
await applyAppleOpenRuntimeHints(input, timing);
115+
await prewarmAppleRunnerBeforeOpen(runner, shouldPrewarmRunner, input.prewarmRunnerBeforeOpen);
116+
const runnerTargetPredatesOpen = runner.wasAwaited();
117+
await dispatchAppleOpen(binding, input, localIosSimulator, timing);
118+
await finishAppleRunnerPrewarm(runner, shouldPrewarmRunner, input.relaunch);
119+
await notifyAppleRunnerRelaunch(
120+
host,
121+
binding,
122+
input,
123+
localIosSimulator,
124+
runnerTargetPredatesOpen,
125+
retainRunnerForRelaunch,
126+
);
127+
await settleAppleOpen(host, binding, localIosSimulator, timing);
128+
return { appBundleId: input.appBundleId, timing };
129+
} catch (error) {
130+
if (retainRunnerForRelaunch) {
131+
await host.appleApplications.stopRunnerSession(binding.device.id).catch(() => {});
132+
}
133+
throw error;
134+
}
115135
}
116136

117137
async function closeAppleApplicationForRelaunch(
118138
host: AppleLifecycleHost,
119139
binding: BoundAppleInteractor,
120140
input: OpenApplicationInput,
121141
localIosSimulator: boolean,
142+
retainRunnerForRelaunch: boolean,
122143
timing: MutableOpenTiming,
123144
): Promise<void> {
124145
if (!shouldCloseForAppleRelaunch(input, localIosSimulator) || !input.target) return;
125146
const startedAtMs = Date.now();
126-
if (isApplePlatform(binding.device.platform) && !localIosSimulator) {
147+
if (isApplePlatform(binding.device.platform) && !localIosSimulator && !retainRunnerForRelaunch) {
127148
await host.appleApplications.stopRunnerSession(binding.device.id);
128149
}
129150
await invokeApplicationClose({
@@ -225,8 +246,15 @@ async function notifyAppleRunnerRelaunch(
225246
input: OpenApplicationInput,
226247
localIosSimulator: boolean,
227248
runnerTargetPredatesOpen: boolean,
249+
retainRunnerForRelaunch: boolean,
228250
): Promise<void> {
229-
if (!localIosSimulator || (!input.relaunch && !runnerTargetPredatesOpen)) return;
251+
if (
252+
!isIosFamily(binding.device) ||
253+
(!localIosSimulator && !retainRunnerForRelaunch) ||
254+
(!input.relaunch && !runnerTargetPredatesOpen)
255+
) {
256+
return;
257+
}
230258
await host.appleApplications.notifyRunnerAppRelaunched(
231259
binding.device,
232260
input.execution,
@@ -255,6 +283,18 @@ function shouldCloseForAppleRelaunch(
255283
);
256284
}
257285

286+
function shouldRetainRunnerForRelaunch(
287+
device: DeviceInfo,
288+
input: OpenApplicationInput,
289+
localIosSimulator: boolean,
290+
): boolean {
291+
return (
292+
device.kind === 'device' &&
293+
device.appleOs === 'ios' &&
294+
shouldCloseForAppleRelaunch(input, localIosSimulator)
295+
);
296+
}
297+
258298
async function closeAppleApplication(
259299
host: AppleLifecycleHost,
260300
binding: BoundAppleInteractor,

src/daemon/handlers/__tests__/session-relaunch-close.test.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,13 @@ test('open --relaunch does not let an ambient provider claim suppress a local pr
157157
}
158158
});
159159

160-
test('open --relaunch on iOS stops runner before close/open', async () => {
160+
test('open --relaunch on physical iOS retains runner through close/open', async () => {
161161
const sessionStore = makeSessionStore();
162162
const sessionName = 'ios-session';
163163
sessionStore.set(sessionName, {
164164
...makeSession(sessionName, {
165165
platform: 'apple',
166+
appleOs: 'ios',
166167
id: 'ios-device-1',
167168
name: 'My iPhone',
168169
kind: 'device',
@@ -174,6 +175,7 @@ test('open --relaunch on iOS stops runner before close/open', async () => {
174175
const calls: string[] = [];
175176
mockResolveTargetDevice.mockResolvedValue({
176177
platform: 'apple',
178+
appleOs: 'ios',
177179
id: 'ios-device-1',
178180
name: 'My iPhone',
179181
kind: 'device',
@@ -182,6 +184,9 @@ test('open --relaunch on iOS stops runner before close/open', async () => {
182184
mockStopIosRunner.mockImplementation(async () => {
183185
calls.push('stop-runner');
184186
});
187+
mockNotifyIosRunnerAppRelaunched.mockImplementation(async () => {
188+
calls.push('reset-runner-target');
189+
});
185190
mockDispatch.mockImplementation(async (_device, command, positionals) => {
186191
calls.push(`${command}:${positionals.join(' ')}`);
187192
return {};
@@ -203,7 +208,8 @@ test('open --relaunch on iOS stops runner before close/open', async () => {
203208

204209
expect(response).toBeTruthy();
205210
expect(response?.ok).toBe(true);
206-
expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']);
211+
expect(calls).toEqual(['close:com.example.app', 'open:com.example.app', 'reset-runner-target']);
212+
expect(mockStopIosRunner).not.toHaveBeenCalled();
207213
});
208214

209215
test('open --relaunch on iOS simulator collapses into one terminate-running open dispatch', async () => {
@@ -368,6 +374,7 @@ test('open --relaunch includes timing and waits for iOS runner prewarm after ope
368374
sessionStore.set(sessionName, {
369375
...makeSession(sessionName, {
370376
platform: 'apple',
377+
appleOs: 'ios',
371378
id: 'ios-device-1',
372379
name: 'My iPhone',
373380
kind: 'device',
@@ -413,13 +420,8 @@ test('open --relaunch includes timing and waits for iOS runner prewarm after ope
413420
const response = await responsePromise;
414421

415422
expect(response?.ok).toBe(true);
416-
expect(events).toEqual([
417-
'stop-runner',
418-
'dispatch:close',
419-
'dispatch:open',
420-
'prewarm-start',
421-
'prewarm-finish',
422-
]);
423+
expect(events).toEqual(['dispatch:close', 'dispatch:open', 'prewarm-start', 'prewarm-finish']);
424+
expect(mockStopIosRunner).not.toHaveBeenCalled();
423425
expect((response as any).data?.timing).toMatchObject({
424426
runnerPrewarmKind: 'session',
425427
runnerPrewarmScheduled: true,
@@ -434,6 +436,7 @@ test('open --relaunch on iOS without existing session closes then opens target a
434436
const sessionName = 'ios-new-session';
435437
mockResolveTargetDevice.mockResolvedValue({
436438
platform: 'apple',
439+
appleOs: 'ios',
437440
id: 'ios-device-1',
438441
name: 'My iPhone',
439442
kind: 'device',
@@ -444,6 +447,9 @@ test('open --relaunch on iOS without existing session closes then opens target a
444447
mockStopIosRunner.mockImplementation(async () => {
445448
calls.push('stop-runner');
446449
});
450+
mockNotifyIosRunnerAppRelaunched.mockImplementation(async () => {
451+
calls.push('reset-runner-target');
452+
});
447453
mockDispatch.mockImplementation(async (_device, command, positionals) => {
448454
calls.push(`${command}:${positionals.join(' ')}`);
449455
return {};
@@ -465,7 +471,8 @@ test('open --relaunch on iOS without existing session closes then opens target a
465471

466472
expect(response).toBeTruthy();
467473
expect(response?.ok).toBe(true);
468-
expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']);
474+
expect(calls).toEqual(['close:com.example.app', 'open:com.example.app', 'reset-runner-target']);
475+
expect(mockStopIosRunner).not.toHaveBeenCalled();
469476
});
470477

471478
test('close on macOS session stops runner and dismisses automation alert before delete', async () => {

0 commit comments

Comments
 (0)