Skip to content

Commit 7ac2f23

Browse files
committed
fix(daemon): allow idle recovery after failed relaunch
1 parent 45e1372 commit 7ac2f23

4 files changed

Lines changed: 127 additions & 20 deletions

File tree

‎packages/daemon/src/sessions/session-lifecycle/internal/session-open-execution-relaunch.test.ts‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import path from 'node:path';
22
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
33
import type { DaemonResponse } from '@agent-device/kernel/contracts';
44
import type { DeviceInfo } from '@agent-device/kernel/device';
5+
import {
6+
acquireDeviceClaim,
7+
inspectDeviceClaims,
8+
} from '@agent-device/daemon-contracts/device-claims';
59
import type {
610
LaunchConfirmationAbsent,
711
LaunchConfirmationOutcome,
@@ -18,7 +22,9 @@ import type { DaemonRequest } from '../../../core/daemon-request.ts';
1822
import type { SessionState } from '../../../core/session-state.ts';
1923
import { refFrameState } from '../../../core/ref-frame.ts';
2024
import { installProviderDeviceAdmission } from '../../../core/provider-device-admission.ts';
25+
import { isDaemonIdle } from '../../server/daemon-idle-reap.ts';
2126
import { daemonPlatformServicesFixture } from '../../../__tests__/support/platform-services-fixture.ts';
27+
import { lifecycleOperationsSpy } from '../../../__tests__/support/lifecycle-operations-spy.ts';
2228
import { fakeCreateCommandSurface } from '../../../__tests__/support/command-surface-fake.ts';
2329
import {
2430
handleSessionCommands,
@@ -189,6 +195,98 @@ test('open --relaunch leaves the old frame expired when the lifecycle fails (ADR
189195
expect(refFrameState(sessionStore.get('android-session')!)).toBe('expired');
190196
});
191197

198+
test('failed relaunch retains the claim for close while allowing daemon idle reap', async () => {
199+
const claimsDir = mkdtempForTestSync('relaunch-claims-');
200+
const previousClaimsDir = process.env.AGENT_DEVICE_CLAIMS_DIR;
201+
process.env.AGENT_DEVICE_CLAIMS_DIR = claimsDir;
202+
try {
203+
const sessionStore = makeSessionStore();
204+
const sessionName = 'android-session';
205+
const acquired = await acquireDeviceClaim({
206+
env: process.env,
207+
device: ANDROID_EMULATOR,
208+
session: sessionName,
209+
workspace: process.cwd(),
210+
stateDir: sessionStore.resolveDaemonStateDir(),
211+
reconcileOrphanedDeviceClaim: async () => ({
212+
status: 'retained',
213+
reason: 'test-no-recovery',
214+
}),
215+
});
216+
if (acquired.status !== 'acquired') throw new Error('claim was not acquired');
217+
publishSession(sessionStore, sessionName, ANDROID_EMULATOR, {
218+
deviceClaim: acquired.ownership,
219+
});
220+
const claims = () => inspectDeviceClaims({ serial: ANDROID_EMULATOR.id }, process.env);
221+
expect(isDaemonIdle({ sessionStore, inFlightRequestCount: 0 })).toBe(false);
222+
223+
await expect(
224+
openRelaunch({
225+
sessionStore,
226+
sessionName,
227+
onOpenApplication: () => {
228+
throw new Error('lifecycle dispatch failed');
229+
},
230+
}),
231+
).rejects.toThrow('lifecycle dispatch failed');
232+
expect(sessionStore.get(sessionName)).toMatchObject({
233+
name: sessionName,
234+
deviceClaim: acquired.ownership,
235+
});
236+
expect(claims()[0]?.claim?.session).toBe(sessionName);
237+
expect(isDaemonIdle({ sessionStore, inFlightRequestCount: 0 })).toBe(true);
238+
239+
expect((await openRelaunch({ sessionStore, sessionName })).response?.ok).toBe(true);
240+
expect(isDaemonIdle({ sessionStore, inFlightRequestCount: 0 })).toBe(false);
241+
await expect(
242+
openRelaunch({
243+
sessionStore,
244+
sessionName,
245+
onOpenApplication: () => {
246+
throw new Error('lifecycle dispatch failed again');
247+
},
248+
}),
249+
).rejects.toThrow('lifecycle dispatch failed again');
250+
251+
const { loader } = lifecycleOperationsSpy();
252+
const closed = await handleSessionCommands({
253+
host: createTestHostEnvironment(),
254+
createCommandSurface: fakeCreateCommandSurface,
255+
platformServices: daemonPlatformServicesFixture(),
256+
lifecycleOperationsLoader: loader,
257+
req: { token: 't', session: sessionName, command: 'close', positionals: [], flags: {} },
258+
sessionName,
259+
logPath: path.join(mkdtempForTestSync('daemon'), 'daemon.log'),
260+
sessionStore,
261+
invoke: noopInvoke,
262+
});
263+
expect(closed?.ok).toBe(true);
264+
expect(sessionStore.get(sessionName)).toBeUndefined();
265+
expect(claims()).toEqual([]);
266+
mockResolveTargetDevice.mockResolvedValue(ANDROID_EMULATOR);
267+
const reopened = await openRelaunch({
268+
sessionStore,
269+
sessionName,
270+
positionals: ['com.example.app'],
271+
flags: { platform: 'android' },
272+
});
273+
expect(reopened.response?.ok).toBe(true);
274+
expect(claims()[0]?.claim?.session).toBe(sessionName);
275+
} finally {
276+
if (previousClaimsDir === undefined) delete process.env.AGENT_DEVICE_CLAIMS_DIR;
277+
else process.env.AGENT_DEVICE_CLAIMS_DIR = previousClaimsDir;
278+
}
279+
});
280+
281+
test('relaunch rejected before dispatch still keeps the daemon active', async () => {
282+
const sessionStore = makeSessionStore();
283+
publishSession(sessionStore, 'android-session', ANDROID_EMULATOR, { appName: undefined });
284+
const { response, opened } = await openRelaunch({ sessionStore, sessionName: 'android-session' });
285+
expect(response?.ok).toBe(false);
286+
expect(opened).toEqual([]);
287+
expect(isDaemonIdle({ sessionStore, inFlightRequestCount: 0 })).toBe(false);
288+
});
289+
192290
test('open --relaunch does not let an ambient provider claim suppress the relaunch', async () => {
193291
const sessionStore = makeSessionStore();
194292
installProviderDeviceAdmission({ isActive: (device) => device.id === ANDROID_EMULATOR.id });

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

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -281,26 +281,33 @@ export async function completeOpenCommand(params: {
281281
? sessionStore.requireCurrent(openDispatchRef)
282282
: undefined;
283283
const openStartedAtMs = Date.now();
284-
const outcome = await lifecycle.operations.openApplication({
285-
target: openTarget,
286-
positionals: openPositionals,
287-
outPath: req.flags?.out,
288-
runtimeLaunchUrl: runtimeHints?.launchUrl,
289-
appBundleId: sessionAppBundleId,
290-
surface,
291-
hasExistingSession: existingSession !== undefined,
292-
relaunch: shouldRelaunch,
293-
prewarmRunnerBeforeOpen: req.flags?.maestro?.prewarmRunnerBeforeOpen === true,
294-
enableTestIme: shouldActivateAndroidTestIme(device, req),
295-
stateDir: sessionStore.resolveDaemonStateDir(),
296-
runtimeHints: runtimeHintValues(runtimeHints),
297-
applyRuntimeHints,
298-
execution: applicationLifecycleExecutionFromRequest(
299-
req,
300-
logPath,
301-
openDispatchSession?.trace?.outPath,
302-
),
303-
});
284+
const outcome = await lifecycle.operations
285+
.openApplication({
286+
target: openTarget,
287+
positionals: openPositionals,
288+
outPath: req.flags?.out,
289+
runtimeLaunchUrl: runtimeHints?.launchUrl,
290+
appBundleId: sessionAppBundleId,
291+
surface,
292+
hasExistingSession: existingSession !== undefined,
293+
relaunch: shouldRelaunch,
294+
prewarmRunnerBeforeOpen: req.flags?.maestro?.prewarmRunnerBeforeOpen === true,
295+
enableTestIme: shouldActivateAndroidTestIme(device, req),
296+
stateDir: sessionStore.resolveDaemonStateDir(),
297+
runtimeHints: runtimeHintValues(runtimeHints),
298+
applyRuntimeHints,
299+
execution: applicationLifecycleExecutionFromRequest(
300+
req,
301+
logPath,
302+
openDispatchSession?.trace?.outPath,
303+
),
304+
})
305+
.catch((error: unknown) => {
306+
if (existingRef && shouldRelaunch && sessionStore.resolveCurrent(existingRef)) {
307+
sessionStore.markCloseEffectsUnconfirmed(existingRef);
308+
}
309+
throw error;
310+
});
304311
sessionAppBundleId = outcome.appBundleId ?? sessionAppBundleId;
305312
const timing: OpenTiming = { ...outcome.timing };
306313
const preparedSelection = markSelectionBootOccurred(selection);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,7 @@ Android physical-device prerequisites:
408408
409409
Runner and daemon lifecycle (applies to simulators too):
410410
open without --relaunch is idempotent-foreground for an already-running app, except native macOS app opens keep it in the background (help macos); neither restarts the process. 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.
411+
If an existing session's relaunch fails after dispatch, close can release its device claim. The session does not keep an idle daemon alive until a successful open.
411412
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.
412413
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 its device lease ends on close. A lease allocated with retainOnClose can keep the runner between sessions; releasing or expiring that lease stops its runner unless a session outside the lease has taken over the device. 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 stopping. Lease release, expiry, and graceful daemon shutdown still clean up runners they own. Only a ready runner with an observed listener is retained; close retires unfinished prewarm recovery. Losing the retained listener stops that generation, and graceful daemon shutdown stops idle retained runners rather than handing them off without supervision.
413414
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.

‎website/docs/docs/commands.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ Open apps and links:
105105
- Android (adb): an intent target such as `open settings` records the package that opened as `appBundleId`. `open settings --relaunch` stops and restarts that app. If the intent matches more than one activity, `--relaunch` fails.
106106
- Android (ADB): `close` force-stops the app, then checks for 2 seconds that it has left the foreground, waits up to 10 seconds for the processes it killed to exit, and checks for 2 more that no new process started. If a check window ends first, `close` fails with `details.reason: android-app-close-timeout` and `details.phase` set to `foreground`, `exit`, or `process`; a process that survives the force-stop fails with `exit`, and apps Android restarts at once, such as the launcher, fail with `process`. When `pidof` cannot list the app's processes, `close` fails with `details.reason: android-process-probe-unavailable`; if that happens before the force-stop, the app keeps running.
107107
- When `close` cannot confirm its cleanup on a session that holds a device claim, it fails and keeps the session and its claim, so the device is not handed on unconfirmed. Run `close` again with the same options to retry the cleanup and release the device, leaving out `--save-script` once the first attempt has published the script. Only `close`, or a successful `open`, is supported on the kept session: other commands work, but the session stays marked, so an idle daemon can still shut it down. If nobody retries, the kept session does not keep the daemon alive: when the daemon has been idle for its idle timeout and shuts down, its shutdown retries the cleanup, and a claim it still cannot confirm goes stale instead of being released. Embedded runtimes and `AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS=0` never shut down for idleness, so there a retried `close` is the only release.
108+
- If `open --relaunch` fails after it starts on an existing session, the session and its claim remain available for `close`. The session does not keep an idle daemon alive until a successful `open`.
108109

109110
iOS Simulator launch behavior:
110111

0 commit comments

Comments
 (0)