Skip to content

Commit 22deb54

Browse files
thymikeeclaude
andauthored
feat: add --shutdown option to close command for iOS simulator teardown (#176)
* feat: add --shutdown option to close command for iOS simulator teardown (#172) When --shutdown is passed, close ends the session then runs xcrun simctl shutdown on the associated simulator. Shutdown result (success, exitCode, stdout, stderr) is included in the JSON response data. The option is silently ignored for non-simulator targets (physical iOS devices, Android). A shutdownSimulator override is injected into handleSessionCommands for testability. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: document close --shutdown option for iOS simulator teardown Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: keep close --shutdown successful when simulator shutdown fails --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent bb06baf commit 22deb54

7 files changed

Lines changed: 256 additions & 1 deletion

File tree

skills/agent-device/references/session-management.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Sessions isolate device context. A device can only be held by one session at a t
2121
- On iOS, `appstate` is session-scoped and requires a matching active session on the target device.
2222
- For dev loops where runtime state can persist (for example React Native Fast Refresh), use `open <app> --relaunch` to restart the app process in the same session.
2323
- Use `--save-script [path]` to record replay scripts on `close`; path is a file path and parent directories are created automatically.
24+
- Use `close --shutdown` (iOS simulator only) to shut down the simulator as part of session teardown, preventing resource leakage in multi-tenant or CI workloads.
2425
- For ambiguous bare `--save-script` values, prefer `--save-script=workflow.ad` or `./workflow.ad`.
2526
- For deterministic replay scripts, prefer selector-based actions and assertions.
2627
- Use `replay -u` to update selector drift during maintenance.

src/__tests__/cli-close.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,22 @@ test('close treats structured daemon startup failure as no-op without relying on
153153
assert.equal(result.stdout, '');
154154
assert.equal(result.stderr, '');
155155
});
156+
157+
test('close --shutdown is accepted as a valid flag', async () => {
158+
const result = await runCliCapture(['close', '--shutdown']);
159+
assert.equal(result.code, null);
160+
assert.equal(result.daemonCalls, 1);
161+
assert.equal(result.stdout, '');
162+
assert.equal(result.stderr, '');
163+
});
164+
165+
test('close --shutdown --json treats daemon startup failure as no-op success', async () => {
166+
const result = await runCliCapture(['close', '--shutdown', '--json']);
167+
assert.equal(result.code, null);
168+
assert.equal(result.daemonCalls, 1);
169+
const payload = JSON.parse(result.stdout);
170+
assert.equal(payload.success, true);
171+
assert.equal(payload.data.closed, 'session');
172+
assert.equal(payload.data.source, 'no-daemon');
173+
assert.equal(result.stderr, '');
174+
});

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2837,3 +2837,179 @@ test('session_list includes device_udid and ios_simulator_device_set for iOS ses
28372837
assert.equal(android?.ios_simulator_device_set, undefined);
28382838
}
28392839
});
2840+
2841+
test('close --shutdown calls shutdownSimulator for iOS simulator and includes result in response', async () => {
2842+
const sessionStore = makeSessionStore();
2843+
const sessionName = 'ios-shutdown-session';
2844+
sessionStore.set(sessionName, makeSession(sessionName, {
2845+
platform: 'ios',
2846+
id: 'sim-udid-1',
2847+
name: 'iPhone 15',
2848+
kind: 'simulator',
2849+
booted: true,
2850+
}));
2851+
2852+
const shutdownCalls: string[] = [];
2853+
const response = await handleSessionCommands({
2854+
req: {
2855+
token: 't',
2856+
session: sessionName,
2857+
command: 'close',
2858+
positionals: [],
2859+
flags: { shutdown: true },
2860+
},
2861+
sessionName,
2862+
logPath: path.join(os.tmpdir(), 'daemon.log'),
2863+
sessionStore,
2864+
invoke: noopInvoke,
2865+
stopIosRunner: async () => {},
2866+
shutdownSimulator: async (device) => {
2867+
shutdownCalls.push(device.id);
2868+
return { success: true, exitCode: 0, stdout: '', stderr: '' };
2869+
},
2870+
});
2871+
2872+
assert.ok(response);
2873+
assert.equal(response?.ok, true);
2874+
assert.deepEqual(shutdownCalls, ['sim-udid-1']);
2875+
assert.equal(sessionStore.get(sessionName), undefined);
2876+
if (response && response.ok) {
2877+
assert.equal(response.data?.session, sessionName);
2878+
assert.deepEqual(response.data?.shutdown, { success: true, exitCode: 0, stdout: '', stderr: '' });
2879+
}
2880+
});
2881+
2882+
test('close --shutdown is ignored for non-simulator iOS devices', async () => {
2883+
const sessionStore = makeSessionStore();
2884+
const sessionName = 'ios-device-shutdown-session';
2885+
sessionStore.set(sessionName, makeSession(sessionName, {
2886+
platform: 'ios',
2887+
id: 'physical-device-1',
2888+
name: 'My iPhone',
2889+
kind: 'device',
2890+
booted: true,
2891+
}));
2892+
2893+
const shutdownCalls: string[] = [];
2894+
const response = await handleSessionCommands({
2895+
req: {
2896+
token: 't',
2897+
session: sessionName,
2898+
command: 'close',
2899+
positionals: [],
2900+
flags: { shutdown: true },
2901+
},
2902+
sessionName,
2903+
logPath: path.join(os.tmpdir(), 'daemon.log'),
2904+
sessionStore,
2905+
invoke: noopInvoke,
2906+
stopIosRunner: async () => {},
2907+
shutdownSimulator: async (device) => {
2908+
shutdownCalls.push(device.id);
2909+
return { success: true, exitCode: 0, stdout: '', stderr: '' };
2910+
},
2911+
});
2912+
2913+
assert.ok(response);
2914+
assert.equal(response?.ok, true);
2915+
assert.deepEqual(shutdownCalls, []);
2916+
assert.equal(sessionStore.get(sessionName), undefined);
2917+
if (response && response.ok) {
2918+
assert.equal(response.data?.session, sessionName);
2919+
assert.equal(response.data?.shutdown, undefined);
2920+
}
2921+
});
2922+
2923+
test('close without --shutdown does not call shutdownSimulator', async () => {
2924+
const sessionStore = makeSessionStore();
2925+
const sessionName = 'ios-no-shutdown-session';
2926+
sessionStore.set(sessionName, makeSession(sessionName, {
2927+
platform: 'ios',
2928+
id: 'sim-udid-2',
2929+
name: 'iPhone 15',
2930+
kind: 'simulator',
2931+
booted: true,
2932+
}));
2933+
2934+
const shutdownCalls: string[] = [];
2935+
const response = await handleSessionCommands({
2936+
req: {
2937+
token: 't',
2938+
session: sessionName,
2939+
command: 'close',
2940+
positionals: [],
2941+
flags: {},
2942+
},
2943+
sessionName,
2944+
logPath: path.join(os.tmpdir(), 'daemon.log'),
2945+
sessionStore,
2946+
invoke: noopInvoke,
2947+
stopIosRunner: async () => {},
2948+
shutdownSimulator: async (device) => {
2949+
shutdownCalls.push(device.id);
2950+
return { success: true, exitCode: 0, stdout: '', stderr: '' };
2951+
},
2952+
});
2953+
2954+
assert.ok(response);
2955+
assert.equal(response?.ok, true);
2956+
assert.deepEqual(shutdownCalls, []);
2957+
if (response && response.ok) {
2958+
assert.equal(response.data?.shutdown, undefined);
2959+
}
2960+
});
2961+
2962+
test('close --shutdown returns success and failure payload when shutdownSimulator throws', async () => {
2963+
const sessionStore = makeSessionStore();
2964+
const sessionName = 'ios-shutdown-failure-session';
2965+
sessionStore.set(sessionName, makeSession(sessionName, {
2966+
platform: 'ios',
2967+
id: 'sim-udid-3',
2968+
name: 'iPhone 15',
2969+
kind: 'simulator',
2970+
booted: true,
2971+
}));
2972+
2973+
const response = await handleSessionCommands({
2974+
req: {
2975+
token: 't',
2976+
session: sessionName,
2977+
command: 'close',
2978+
positionals: [],
2979+
flags: { shutdown: true },
2980+
},
2981+
sessionName,
2982+
logPath: path.join(os.tmpdir(), 'daemon.log'),
2983+
sessionStore,
2984+
invoke: noopInvoke,
2985+
stopIosRunner: async () => {},
2986+
shutdownSimulator: async () => {
2987+
throw new AppError('COMMAND_FAILED', 'simctl shutdown failed');
2988+
},
2989+
});
2990+
2991+
assert.ok(response);
2992+
assert.equal(response?.ok, true);
2993+
assert.equal(sessionStore.get(sessionName), undefined);
2994+
if (response && response.ok) {
2995+
const shutdown = response.data?.shutdown as
2996+
| {
2997+
success?: boolean;
2998+
exitCode?: number;
2999+
stdout?: string;
3000+
stderr?: string;
3001+
error?: {
3002+
code?: string;
3003+
message?: string;
3004+
};
3005+
}
3006+
| undefined;
3007+
assert.equal(response.data?.session, sessionName);
3008+
assert.equal(shutdown?.success, false);
3009+
assert.equal(shutdown?.exitCode, -1);
3010+
assert.equal(shutdown?.stdout, '');
3011+
assert.equal(shutdown?.stderr, 'simctl shutdown failed');
3012+
assert.equal(shutdown?.error?.code, 'COMMAND_FAILED');
3013+
assert.equal(shutdown?.error?.message, 'simctl shutdown failed');
3014+
}
3015+
});

src/daemon/handlers/session.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { SessionStore } from '../session-store.ts';
1717
import { contextFromFlags } from '../context.ts';
1818
import { ensureDeviceReady } from '../device-ready.ts';
1919
import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
20+
import { shutdownSimulator } from '../../platforms/ios/simulator.ts';
2021
import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
2122
import { pruneGroupNodes } from '../snapshot-processing.ts';
2223
import {
@@ -712,6 +713,7 @@ export async function handleSessionCommands(params: {
712713
openTarget: string | undefined,
713714
) => Promise<string | undefined>;
714715
settleSimulator?: typeof settleIosSimulator;
716+
shutdownSimulator?: typeof shutdownSimulator;
715717
}): Promise<DaemonResponse | null> {
716718
const {
717719
req,
@@ -732,12 +734,14 @@ export async function handleSessionCommands(params: {
732734
ensureAndroidEmulatorBoot: ensureAndroidEmulatorBootOverride = defaultEnsureAndroidEmulatorBoot,
733735
resolveAndroidPackageForOpen: resolveAndroidPackageForOpenOverride = resolveAndroidPackageForOpen,
734736
settleSimulator: settleSimulatorOverride,
737+
shutdownSimulator: shutdownSimulatorOverride,
735738
} = params;
736739
const dispatch = dispatchOverride ?? dispatchCommand;
737740
const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
738741
const resolveDevice = resolveTargetDeviceOverride ?? resolveTargetDevice;
739742
const stopIosRunner = stopIosRunnerOverride ?? stopIosRunnerSession;
740743
const settleSimulator = settleSimulatorOverride ?? settleIosSimulator;
744+
const doShutdownSimulator = shutdownSimulatorOverride ?? shutdownSimulator;
741745
const command = req.command;
742746

743747
if (command === 'session_list') {
@@ -1583,6 +1587,31 @@ export async function handleSessionCommands(params: {
15831587
}
15841588
sessionStore.writeSessionLog(session);
15851589
sessionStore.delete(sessionName);
1590+
if (req.flags?.shutdown && isIosSimulator(session.device)) {
1591+
let shutdownResult: {
1592+
success: boolean;
1593+
exitCode: number;
1594+
stdout: string;
1595+
stderr: string;
1596+
error?: ReturnType<typeof normalizeError>;
1597+
};
1598+
try {
1599+
shutdownResult = await doShutdownSimulator(session.device);
1600+
} catch (error) {
1601+
const normalized = normalizeError(error);
1602+
shutdownResult = {
1603+
success: false,
1604+
exitCode: -1,
1605+
stdout: '',
1606+
stderr: normalized.message,
1607+
error: normalized,
1608+
};
1609+
}
1610+
return {
1611+
ok: true,
1612+
data: { session: sessionName, shutdown: shutdownResult },
1613+
};
1614+
}
15861615
return { ok: true, data: { session: sessionName } };
15871616
}
15881617

src/platforms/ios/simulator.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,22 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
142142
}
143143
}
144144

145+
export async function shutdownSimulator(device: DeviceInfo): Promise<{
146+
success: boolean;
147+
exitCode: number;
148+
stdout: string;
149+
stderr: string;
150+
}> {
151+
const args = buildSimctlArgsForDevice(device, ['shutdown', device.id]);
152+
const result = await runCmd('xcrun', args, { allowFailure: true, timeoutMs: 15_000 });
153+
return {
154+
success: result.exitCode === 0,
155+
exitCode: result.exitCode,
156+
stdout: String(result.stdout ?? ''),
157+
stderr: String(result.stderr ?? ''),
158+
};
159+
}
160+
145161
export async function getSimulatorState(deviceOrUdid: DeviceInfo | string): Promise<string | null> {
146162
const udid = typeof deviceOrUdid === 'string' ? deviceOrUdid : deviceOrUdid.id;
147163
const simctlArgs = typeof deviceOrUdid === 'string'

src/utils/command-schema.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export type CliFlags = {
3838
pattern?: 'one-way' | 'ping-pong';
3939
activity?: string;
4040
saveScript?: boolean | string;
41+
shutdown?: boolean;
4142
relaunch?: boolean;
4243
headless?: boolean;
4344
restart?: boolean;
@@ -347,6 +348,13 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
347348
usageLabel: '--save-script [path]',
348349
usageDescription: 'Save session script (.ad) on close; optional custom output path',
349350
},
351+
{
352+
key: 'shutdown',
353+
names: ['--shutdown'],
354+
type: 'boolean',
355+
usageLabel: '--shutdown',
356+
usageDescription: 'close: shutdown associated iOS simulator after ending session',
357+
},
350358
{
351359
key: 'relaunch',
352360
names: ['--relaunch'],
@@ -504,7 +512,7 @@ const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
504512
close: {
505513
description: 'Close app or just end session',
506514
positionalArgs: ['app?'],
507-
allowedFlags: ['saveScript'],
515+
allowedFlags: ['saveScript', 'shutdown'],
508516
},
509517
reinstall: {
510518
description: 'Uninstall + install app from binary path',

website/docs/docs/sessions.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ agent-device snapshot -i
2121
agent-device close --session my-session
2222
```
2323

24+
Shut down the simulator on close (iOS simulator only, prevents resource leakage in CI/multi-tenant workloads):
25+
26+
```bash
27+
agent-device close --platform ios --shutdown
28+
```
29+
2430
Notes:
2531

2632
- `open <app>` within an existing session switches the active app and updates the session bundle id.

0 commit comments

Comments
 (0)