Skip to content

Commit 9c71e66

Browse files
thymikeeclaude
andcommitted
fix(ios): give an unverifiable simulator state its own typed reason and cause text (#3321)
runner_unreachable meant both 'the runner stopped answering' and 'our own state probe could not be read'. The probe verdict is now runner_destination_unverified, and the open warning's cause text is a total map over the reasons. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1 parent 791bdd6 commit 9c71e66

6 files changed

Lines changed: 68 additions & 12 deletions

File tree

‎packages/platform-apple/src/runner/__tests__/runner-destination-watch.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,10 @@ test('an unreadable device state stops the runner without claiming a shutdown',
172172
(await listener.nextConnection()).destroy();
173173
await vi.waitFor(() => assert.equal(onStop.mock.calls.length, 1));
174174

175-
assert.equal((await takeRunnerWarmLossNotice(DEVICE.id))?.reason, 'runner_unreachable');
175+
assert.equal(
176+
(await takeRunnerWarmLossNotice(DEVICE.id))?.reason,
177+
'runner_destination_unverified',
178+
);
176179
});
177180

178181
test('a boot unobservable on both reads while the destination process lives is a loss', async () => {

‎packages/platform-apple/src/runner/runner-destination-watch.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111
* Why a runner retained after `close` was stopped before the session asked for it again.
1212
* Keyed behavior: consumers branch on these values, never on prose.
1313
*/
14-
export type RunnerWarmLossReason = 'runner_destination_lost' | 'runner_unreachable';
14+
export type RunnerWarmLossReason =
15+
| 'runner_destination_lost'
16+
| 'runner_unreachable'
17+
| 'runner_destination_unverified';
1518

1619
/**
1720
* What a retained runner's owner records when the watcher stops it: the typed reason, the session
@@ -80,7 +83,10 @@ const DESTINATION_LOST: LossVerdict = { action: 'stop', noticeReason: 'runner_de
8083
* The listing timed out or was unreadable, so nothing proves the device changed. Stopping is still
8184
* the safe side, but the notice must not claim a shutdown nobody observed.
8285
*/
83-
const STATE_UNVERIFIABLE: LossVerdict = { action: 'stop', noticeReason: 'runner_unreachable' };
86+
const STATE_UNVERIFIABLE: LossVerdict = {
87+
action: 'stop',
88+
noticeReason: 'runner_destination_unverified',
89+
};
8490

8591
type DestinationWatch = {
8692
device: DeviceInfo;
@@ -342,7 +348,7 @@ async function handleRefusedAttach(
342348
* boot the window began with, and anything else is destination loss: a shut-down device keeps its
343349
* old `launchd_sim` listed for ~6s, so the boot witness alone would read the old boot and call it
344350
* a crash. A listing that timed out or was unreadable is no evidence of a healthy device, so it
345-
* stops the runner too, but as `runner_unreachable`: it cannot support a claim about the device. A boot newer than the
351+
* stops the runner too, but as `runner_destination_unverified`: it cannot support a claim about the device. A boot newer than the
346352
* window is Xcode's reboot; a boot unobservable twice is a device that is down with a reboot
347353
* promise that may already be in flight. Stopping pre-empts that promise in every loss case.
348354
*/

‎src/commands/schema/cli-help.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -530,7 +530,7 @@ Android physical-device prerequisites:
530530
Runner and daemon lifecycle (applies to simulators too):
531531
open without --relaunch is idempotent-foreground for an already-running app (it brings the process forward; it does not restart it). open --relaunch restarts the app; on iOS simulators this collapses to one simctl launch --terminate-running-process call instead of a separate terminate-then-launch.
532532
No runner read launches a session app that is not running: snapshot, wait, is, get, a reading find, and an interaction's leading reads (a gesture's viewport read, the capture that resolves a selector click/fill) answer the retriable APP_NOT_RUNNING instead of bare-launching over a launch SpringBoard still holds behind its deep-link confirmation. Only open, activate, and a command that mutates without a leading read bring a stopped app up.
533-
close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or the session held a device lease. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. A simulator shut down externally while its runner is retained is detected and the retained runner is stopped, because the retained Xcode destination would otherwise reboot the simulator and leave it powered on; the next open reports reason=runner_destination_lost (or reason=runner_unreachable when the runner stopped answering) in its warnings.
533+
close keeps a healthy iOS simulator XCTest runner warm by default so the next open on that simulator (same udid in the same simulator set) skips the runner build, unless --shutdown was requested, the session was recording, or the session held a device lease. A retained runner auto-stops after an idle window (default 5 minutes); set AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS to override, or 0 to disable idle stop and retain until daemon exit. A simulator shut down externally while its runner is retained is detected and the retained runner is stopped, because the retained Xcode destination would otherwise reboot the simulator and leave it powered on; the next open reports reason=runner_destination_lost in its warnings, or reason=runner_unreachable when the runner stopped answering, or reason=runner_destination_unverified when the runner's connection closed and the simulator's state could not be read.
534534
Each AGENT_DEVICE_STATE_DIR runs its own daemon. It self-exits after an idle window (default 5 minutes, matching the runner idle-stop default) once it has no open sessions, no in-flight requests, and no active recording; set AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS to override, or 0 to disable idle reap.
535535
On a machine shared by several agents, set AGENT_DEVICE_SESSION_IDLE_TIMEOUT_MS to also expire an individual session that has taken a host-global device claim and then received no commands for that long; the claim is released and the next command on that session answers SESSION_NOT_FOUND with details.reason SESSION_IDLE_EXPIRED naming the window and the device. Off by default, because on a shared host "idle" and "thinking" are indistinguishable: only set it where an abandoned claim blocking every other agent is the worse failure. Sessions holding a remote lease or any active capture (recording, logs, audio, performance, or trace) are never expired this way.
536536
A stale iOS runner lease — its owner process dead, or its AGENT_DEVICE_STATE_DIR deleted — is reclaimed automatically instead of failing with "is already owned by another agent-device daemon". A live owner's runner is also reclaimed when the requesting daemon holds the host-global device claim for that device: claims are exclusive, so holding one proves the runner's owner released the device and merely kept the runner warm. The error remains only for owners outside claim arbitration (a pre-claims build, or daemons pointed at different claim stores).

‎src/daemon/__tests__/request-router-open-claim.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,49 @@ test('open warns with the typed reason when a warm runner was stopped for a lost
213213
}
214214
});
215215

216+
test('open warns with its own typed reason when a warm runner was stopped on an unverifiable state', async () => {
217+
const sessionStore = makeSessionStore('agent-device-router-open-warm-unverified-');
218+
const device = makeIosDevice('SIM-WARM-UNVERIFIED');
219+
mockResolveTargetDevice.mockResolvedValue(device);
220+
mockTakeRunnerWarmLossNotice.mockResolvedValueOnce({
221+
reason: 'runner_destination_unverified',
222+
deviceId: device.id,
223+
sessionId: 'warm-unverified',
224+
atMs: Date.now(),
225+
});
226+
const claimsDir = mkdtempForTestSync('agent-device-router-open-warm-unverified-claims-');
227+
const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR;
228+
process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir;
229+
230+
try {
231+
const opened = await createOpenHandler(sessionStore)(
232+
openRequest('warm-unverified', { platform: 'ios' }, 'req-open-warm-unverified', {}, [
233+
'FixtureApp',
234+
]),
235+
);
236+
237+
expect(opened.ok).toBe(true);
238+
if (!opened.ok) return;
239+
expect(mockTakeRunnerWarmLossNotice).toHaveBeenCalledWith(device.id);
240+
expect(opened.data?.warnings).toEqual([
241+
expect.stringContaining('reason=runner_destination_unverified'),
242+
]);
243+
expect(String(opened.data?.warnings)).not.toContain('stopped answering');
244+
245+
const reopened = await createOpenHandler(sessionStore)(
246+
openRequest('warm-unverified', { platform: 'ios' }, 'req-open-warm-unverified-again', {}, [
247+
'FixtureApp',
248+
]),
249+
);
250+
expect(reopened.ok).toBe(true);
251+
if (reopened.ok) expect(reopened.data?.warnings).toBeUndefined();
252+
} finally {
253+
if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR;
254+
else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir;
255+
fs.rmSync(claimsDir, { recursive: true, force: true });
256+
}
257+
});
258+
216259
// The production reopen path never re-acquires the claim, so renewal has to ride the successful
217260
// existing-session open itself: without it, an owner that came back after a reboot still carries a
218261
// pre-reboot stamp and loses the device to the next caller that asks.

‎src/daemon/session-lifecycle/internal/session-open-execution.ts‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -141,14 +141,18 @@ async function readWarmRunnerLossNotice(
141141
* be stopped because the Simulator was shut down externally while it was retained (#3321). Names
142142
* the typed reason so automation can key on it without parsing prose.
143143
*/
144+
const WARM_RUNNER_LOSS_CAUSE: Record<RunnerWarmLossNotice['reason'], string> = {
145+
runner_destination_lost:
146+
'the simulator was shut down externally while it was retained, so the runner was stopped before it could boot the simulator again',
147+
runner_unreachable: 'it stopped answering while it was retained',
148+
runner_destination_unverified:
149+
"its connection closed while it was retained and the simulator's state could not be read, so the runner was stopped as a precaution",
150+
};
151+
144152
function warmRunnerLossWarning(notice: RunnerWarmLossNotice): string {
145-
const cause =
146-
notice.reason === 'runner_destination_lost'
147-
? 'the simulator was shut down externally while it was retained, so the runner was stopped before it could boot the simulator again'
148-
: 'it stopped answering while it was retained';
149153
return (
150-
`The warm iOS runner left by a previous close was stopped at ${new Date(notice.atMs).toISOString()}: ${cause}. ` +
151-
`reason=${notice.reason}`
154+
`The warm iOS runner left by a previous close was stopped at ${new Date(notice.atMs).toISOString()}: ` +
155+
`${WARM_RUNNER_LOSS_CAUSE[notice.reason]}. reason=${notice.reason}`
152156
);
153157
}
154158

‎website/docs/docs/sessions.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ agent-device close --shutdown
117117
Without `--shutdown`, `close` keeps the iOS runner warm for the next `open`. If you shut that simulator
118118
down yourself (`xcrun simctl shutdown`) while the runner is waiting, agent-device stops the runner so it
119119
doesn't boot the simulator again, and the next `open` returns a warning that ends in
120-
`reason=runner_destination_lost`, or `reason=runner_unreachable` when the runner stopped answering.
120+
`reason=runner_destination_lost`. It ends in `reason=runner_unreachable` when the runner stopped answering, or `reason=runner_destination_unverified` when its connection closed and the simulator's state could not be read.
121121

122122
## Wait for a slow or busy device
123123

0 commit comments

Comments
 (0)