Skip to content

Commit d022799

Browse files
authored
feat: add daemon stop lifecycle (#1323)
* feat: add daemon stop lifecycle * fix: harden daemon stop cleanup * fix: fail closed daemon stop cleanup * fix: bound daemon shutdown lease releases * fix: await active shutdown lease release * fix: release provider leases independently on shutdown
1 parent 12500dd commit d022799

20 files changed

Lines changed: 1097 additions & 30 deletions

scripts/integration-progress-model.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,11 @@ function summarizeProviderScenarioFlagExclusions() {
319319
'proxyPort',
320320
],
321321
},
322+
{
323+
name: 'daemon lifecycle control',
324+
owner: 'daemon CLI lifecycle tests',
325+
keys: ['clean'],
326+
},
322327
{
323328
name: 'platform boot fallback without provider seam',
324329
owner: 'handler and Android platform unit tests',

src/cli-schema/command-overrides.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@ const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = {
2929
positionalArgs: ['status|login|logout'],
3030
supportedFlags: ['remoteConfig', 'stateDir'],
3131
},
32+
daemon: {
33+
usageOverride: 'daemon stop [--state-dir <path>] [--clean]',
34+
listUsageOverride: 'daemon stop',
35+
helpDescription:
36+
'Stop a local daemon after verifying its PID/start-time identity. Use --clean to remove retained Apple runner processes and leases owned by that daemon.',
37+
summary: 'Safely stop a local daemon and optionally clean retained runner resources',
38+
positionalArgs: ['stop'],
39+
allowedFlags: ['clean'],
40+
supportedFlags: ['stateDir'],
41+
},
3242
connect: {
3343
usageOverride:
3444
'connect [cloud|proxy|limrun|browserstack|aws-device-farm] [--remote-config <path>] [--daemon-base-url <url>] [--tenant <id>] [--run-id <id>] [--lease-id <id>] [--lease-backend <backend>] [--force] [--no-login]',

src/cli.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([
7575
'connect',
7676
'connection',
7777
'close',
78+
'daemon',
7879
'disconnect',
7980
'metro',
8081
'proxy',
@@ -554,6 +555,7 @@ function resolveActiveConnectionDefaults(options: {
554555
if (
555556
options.command === 'connect' ||
556557
options.command === 'connection' ||
558+
options.command === 'daemon' ||
557559
options.command === 'proxy'
558560
) {
559561
return null;
@@ -577,7 +579,9 @@ function shouldMaterializeRemoteConnection(command: string): boolean {
577579
}
578580

579581
function shouldResolveRemoteAuth(command: string): boolean {
580-
return command !== 'auth' && command !== 'connection' && command !== 'proxy';
582+
return (
583+
command !== 'auth' && command !== 'connection' && command !== 'daemon' && command !== 'proxy'
584+
);
581585
}
582586

583587
function shouldWarnOpenMayMissRemoteRuntime(options: {
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { afterEach, expect, test, vi } from 'vitest';
6+
import type { DaemonStopResult } from '../../../daemon/daemon-stop.ts';
7+
8+
const mocks = vi.hoisted(() => ({
9+
cleanupRunnerLeasesForOwner: vi.fn(async () => undefined),
10+
readDaemonShutdownReport: vi.fn(),
11+
readDaemonStopIdentity: vi.fn(),
12+
stopDaemon: vi.fn(),
13+
writeCommandOutput: vi.fn(),
14+
}));
15+
16+
vi.mock('../../../daemon/daemon-stop.ts', () => ({
17+
readDaemonStopIdentity: mocks.readDaemonStopIdentity,
18+
stopDaemon: mocks.stopDaemon,
19+
}));
20+
vi.mock('../../../daemon/daemon-shutdown-report.ts', () => ({
21+
readDaemonShutdownReport: mocks.readDaemonShutdownReport,
22+
}));
23+
vi.mock('../../../platforms/apple/core/runner/runner-lease.ts', () => ({
24+
cleanupRunnerLeasesForOwner: mocks.cleanupRunnerLeasesForOwner,
25+
}));
26+
vi.mock('../../../platforms/apple/core/runner/runner-disposal.ts', () => ({
27+
runnerLeaseCleanupAdapter: {},
28+
}));
29+
vi.mock('../shared.ts', () => ({ writeCommandOutput: mocks.writeCommandOutput }));
30+
31+
import { daemonCommand } from '../daemon.ts';
32+
33+
const GRACEFUL_RESULT: DaemonStopResult = {
34+
stopped: true,
35+
mode: 'graceful',
36+
cleanupConfidence: 'known',
37+
claimsReleased: [],
38+
claimsOrphaned: [],
39+
providerReleases: { status: 'completed', released: [], pending: [] },
40+
warnings: [],
41+
};
42+
43+
afterEach(() => {
44+
vi.clearAllMocks();
45+
});
46+
47+
test('accepts only daemon stop', async () => {
48+
await assert.rejects(
49+
async () =>
50+
await daemonCommand({
51+
positionals: [],
52+
flags: { help: false, json: false, version: false },
53+
client: {} as never,
54+
}),
55+
(error: { code?: string }) => error.code === 'INVALID_ARGS',
56+
);
57+
await assert.rejects(
58+
async () =>
59+
await daemonCommand({
60+
positionals: ['stop', 'extra'],
61+
flags: { help: false, json: false, version: false },
62+
client: {} as never,
63+
}),
64+
(error: { code?: string }) => error.code === 'INVALID_ARGS',
65+
);
66+
});
67+
68+
test('merges a graceful shutdown report and cleans runner leases with the start-time identity', async () => {
69+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-command-'));
70+
mocks.readDaemonStopIdentity.mockReturnValue({ pid: 123, processStartTime: 'start-time' });
71+
mocks.stopDaemon.mockResolvedValue(GRACEFUL_RESULT);
72+
mocks.readDaemonShutdownReport.mockReturnValue({
73+
providerReleases: {
74+
released: [{ leaseId: 'lease-1', provider: 'limrun' }],
75+
pending: [],
76+
},
77+
});
78+
79+
try {
80+
await daemonCommand({
81+
positionals: ['stop'],
82+
flags: { clean: true, help: false, json: false, stateDir, version: false },
83+
client: {} as never,
84+
});
85+
86+
expect(mocks.cleanupRunnerLeasesForOwner).toHaveBeenCalledWith(
87+
{ pid: 123, startTime: 'start-time' },
88+
{},
89+
);
90+
expect(mocks.writeCommandOutput).toHaveBeenCalledWith(
91+
expect.objectContaining({ clean: true, json: false }),
92+
expect.objectContaining({
93+
clean: true,
94+
providerReleases: {
95+
status: 'completed',
96+
released: [{ leaseId: 'lease-1', provider: 'limrun' }],
97+
pending: [],
98+
},
99+
}),
100+
expect.any(Function),
101+
);
102+
} finally {
103+
fs.rmSync(stateDir, { recursive: true, force: true });
104+
}
105+
});
106+
107+
test('reports graceful provider cleanup as unknown when the shutdown report is unavailable', async () => {
108+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-daemon-command-'));
109+
mocks.readDaemonStopIdentity.mockReturnValue(null);
110+
mocks.stopDaemon.mockResolvedValue(GRACEFUL_RESULT);
111+
mocks.readDaemonShutdownReport.mockReturnValue(null);
112+
113+
try {
114+
await daemonCommand({
115+
positionals: ['stop'],
116+
flags: { help: false, json: false, stateDir, version: false },
117+
client: {} as never,
118+
});
119+
120+
expect(mocks.writeCommandOutput).toHaveBeenCalledWith(
121+
expect.objectContaining({ json: false }),
122+
expect.objectContaining({
123+
clean: false,
124+
cleanupConfidence: 'unknown',
125+
providerReleases: { status: 'unknown', released: [], pending: null },
126+
warnings: [expect.stringContaining('provider cleanup state is unknown')],
127+
}),
128+
expect.any(Function),
129+
);
130+
} finally {
131+
fs.rmSync(stateDir, { recursive: true, force: true });
132+
}
133+
});

src/cli/commands/daemon.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { resolveDaemonPaths } from '../../daemon/config.ts';
2+
import {
3+
readDaemonStopIdentity,
4+
stopDaemon,
5+
type DaemonStopResult,
6+
} from '../../daemon/daemon-stop.ts';
7+
import { readDaemonShutdownReport } from '../../daemon/daemon-shutdown-report.ts';
8+
import { AppError } from '../../kernel/errors.ts';
9+
import { writeCommandOutput } from './shared.ts';
10+
import type { ClientCommandHandler } from './router-types.ts';
11+
12+
export const daemonCommand: ClientCommandHandler = async ({ positionals, flags }) => {
13+
const subcommand = positionals[0];
14+
if (subcommand !== 'stop' || positionals.length !== 1) {
15+
throw new AppError('INVALID_ARGS', 'daemon accepts only: stop');
16+
}
17+
const paths = resolveDaemonPaths(flags.stateDir);
18+
const identity = readDaemonStopIdentity(paths.infoPath);
19+
const stopped = await stopDaemon({ paths });
20+
const report = stopped.mode === 'graceful' ? readDaemonShutdownReport(paths.baseDir) : null;
21+
const result = mergeShutdownReport(stopped, report);
22+
const shouldClean = flags.clean === true && identity !== null && result.stopped;
23+
if (shouldClean) {
24+
const [{ cleanupRunnerLeasesForOwner }, { runnerLeaseCleanupAdapter }] = await Promise.all([
25+
import('../../platforms/apple/core/runner/runner-lease.ts'),
26+
import('../../platforms/apple/core/runner/runner-disposal.ts'),
27+
]);
28+
await cleanupRunnerLeasesForOwner(
29+
{ pid: identity.pid, startTime: identity.processStartTime },
30+
runnerLeaseCleanupAdapter,
31+
);
32+
}
33+
const data = { ...result, clean: shouldClean };
34+
writeCommandOutput(flags, data, () => renderDaemonStop(data));
35+
return true;
36+
};
37+
38+
function mergeShutdownReport(
39+
stopped: DaemonStopResult,
40+
report: ReturnType<typeof readDaemonShutdownReport>,
41+
): DaemonStopResult {
42+
if (stopped.mode !== 'graceful' || report) {
43+
return report
44+
? { ...stopped, providerReleases: { status: 'completed', ...report.providerReleases } }
45+
: stopped;
46+
}
47+
return {
48+
...stopped,
49+
cleanupConfidence: 'unknown',
50+
providerReleases: { status: 'unknown', released: [], pending: null },
51+
warnings: [
52+
...stopped.warnings,
53+
'The graceful shutdown report is unavailable, so provider cleanup state is unknown. Provider allocations may remain active.',
54+
],
55+
};
56+
}
57+
58+
function renderDaemonStop(
59+
result: Pick<DaemonStopResult, 'stopped' | 'mode' | 'warnings'> & {
60+
clean: boolean;
61+
},
62+
): string {
63+
const headline = result.stopped ? `Daemon stopped (${result.mode}).` : 'No running daemon found.';
64+
return [headline, result.clean ? 'Retained runner cleanup completed.' : null, ...result.warnings]
65+
.filter((line): line is string => Boolean(line))
66+
.join('\n');
67+
}

src/cli/commands/router.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { AgentDeviceClient } from '../../agent-device-client.ts';
33
import { isClientBackedCliCommandName, type ClientBackedCliCommandName } from './client-backed.ts';
44
import { connectCommand, connectionCommand, disconnectCommand } from './connection.ts';
55
import { authCommand } from './auth.ts';
6+
import { daemonCommand } from './daemon.ts';
67
import { proxyCommand } from './proxy.ts';
78
import { replayCommand } from './replay.ts';
89
import { screenshotCommand, diffCommand } from './screenshot.ts';
@@ -19,6 +20,7 @@ const dedicatedCliCommandHandlers = {
1920
disconnect: disconnectCommand,
2021
connection: connectionCommand,
2122
auth: authCommand,
23+
daemon: daemonCommand,
2224
proxy: proxyCommand,
2325
replay: replayCommand,
2426
screenshot: screenshotCommand,

src/commands/cli-grammar/flag-definitions-connection.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,13 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
201201
usageDescription:
202202
'Force replacement of existing state (connect: replace an active connection; --save-script: overwrite an existing target instead of refusing)',
203203
},
204+
{
205+
key: 'clean',
206+
names: ['--clean'],
207+
type: 'boolean',
208+
usageLabel: '--clean',
209+
usageDescription: 'Daemon stop: remove retained runner processes and leases after stopping',
210+
},
204211
{
205212
key: 'noLogin',
206213
names: ['--no-login'],

src/contracts/cli-flags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export type CliFlags = CloudProviderProfileFields &
4242
provider?: string;
4343
providerSessionId?: string;
4444
force?: boolean;
45+
clean?: boolean;
4546
noLogin?: boolean;
4647
kind?: string;
4748
perfTemplate?: string;

src/core/command-descriptor/registry.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,6 +1075,15 @@ export const RAW_COMMAND_DESCRIPTORS = [
10751075
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
10761076
batchable: false,
10771077
},
1078+
{
1079+
name: 'daemon',
1080+
...(ownerFilesEnabled ? { ownerFiles: ['src/cli/commands/daemon.ts'] as const } : {}),
1081+
catalog: { group: 'local-cli' },
1082+
recordsSessionAction: false,
1083+
timeoutPolicy: DEFAULT_TIMEOUT_POLICY,
1084+
batchable: false,
1085+
mcpExposed: false,
1086+
},
10781087
{
10791088
name: 'metro',
10801089
...(ownerFilesEnabled ? { ownerFiles: ['src/commands/metro/index.ts'] as const } : {}),
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
import { expect, test } from 'vitest';
5+
import {
6+
clearDaemonShutdownReport,
7+
readDaemonShutdownReport,
8+
writeDaemonShutdownReport,
9+
} from '../daemon-shutdown-report.ts';
10+
import { LeaseRegistry } from '../lease-registry.ts';
11+
12+
test('round-trips provider release records without persisting lease credentials', () => {
13+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-shutdown-report-'));
14+
const lease = new LeaseRegistry().allocateLease({
15+
tenantId: 'tenant-a',
16+
runId: 'run-1',
17+
leaseProvider: 'limrun',
18+
});
19+
20+
try {
21+
writeDaemonShutdownReport(stateDir, { released: [lease], pending: [lease] });
22+
23+
expect(readDaemonShutdownReport(stateDir)).toEqual({
24+
providerReleases: {
25+
released: [{ leaseId: lease.leaseId, provider: 'limrun' }],
26+
pending: [{ leaseId: lease.leaseId, provider: 'limrun' }],
27+
},
28+
});
29+
} finally {
30+
fs.rmSync(stateDir, { recursive: true, force: true });
31+
}
32+
});
33+
34+
test('ignores malformed shutdown reports and clear removes a prior report', () => {
35+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-shutdown-report-'));
36+
const reportPath = path.join(stateDir, 'daemon-shutdown.json');
37+
38+
try {
39+
expect(readDaemonShutdownReport(stateDir)).toBeNull();
40+
fs.writeFileSync(reportPath, JSON.stringify({ providerReleases: { released: [] } }));
41+
expect(readDaemonShutdownReport(stateDir)).toBeNull();
42+
43+
fs.writeFileSync(
44+
reportPath,
45+
JSON.stringify({ providerReleases: { released: [{}], pending: [] } }),
46+
);
47+
expect(readDaemonShutdownReport(stateDir)).toBeNull();
48+
49+
clearDaemonShutdownReport(stateDir);
50+
expect(fs.existsSync(reportPath)).toBe(false);
51+
} finally {
52+
fs.rmSync(stateDir, { recursive: true, force: true });
53+
}
54+
});

0 commit comments

Comments
 (0)