Skip to content

Commit 3dfb4ac

Browse files
committed
fix(daemon): preserve shutdown cleanup identity
1 parent 26ef934 commit 3dfb4ac

2 files changed

Lines changed: 102 additions & 10 deletions

File tree

src/daemon/__tests__/daemon-process-takeover.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,19 @@ test('does not stop a branch-named daemon when process identity is missing', asy
6767
await stopProcessForTakeover(pid, { ...TAKEOVER_TIMEOUTS, expectedStartTime: undefined });
6868
assert.equal(isProcessAlive(pid), true);
6969
});
70+
71+
test('does not stop a branch-named daemon when the pid belongs to a different process lifetime', async () => {
72+
const { pid, entryPath } = spawnFakeDaemonFromBranchNamedCheckout();
73+
assert.equal(entryPath.toLowerCase().includes('agent-device'), false);
74+
75+
const actualStartTime = readProcessStartTime(pid);
76+
assert.ok(actualStartTime, 'expected the spawned daemon to report a start time');
77+
const staleStartTime = `${actualStartTime}-previous-lifetime`;
78+
assert.equal(isAgentDeviceDaemonProcess(pid, staleStartTime), false);
79+
80+
await stopProcessForTakeover(pid, {
81+
...TAKEOVER_TIMEOUTS,
82+
expectedStartTime: staleStartTime,
83+
});
84+
assert.equal(isProcessAlive(pid), true);
85+
});

test/integration/smoke-web-platform.test.ts

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from 'node:assert/strict';
2-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2+
import { spawn } from 'node:child_process';
3+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
34
import { createServer, type Server } from 'node:http';
45
import path from 'node:path';
56
import test from 'node:test';
@@ -21,6 +22,7 @@ import {
2122
expandProcessTree,
2223
isProcessAlive,
2324
listHostProcesses,
25+
readProcessStartTime,
2426
stopPidsWithEscalation,
2527
} from '../../src/utils/host-process.ts';
2628

@@ -38,6 +40,73 @@ const WEB_SHUTDOWN_CLEANUP_TIMEOUT_MS = 5_000;
3840
// actively closed the browser, not that agent-browser's own idle timer beat this test's own wait.
3941
const WEB_SHUTDOWN_IDLE_TIMEOUT_MS = WEB_SHUTDOWN_SETTLE_TIMEOUT_MS + 60_000;
4042

43+
type WebShutdownDaemonIdentity = {
44+
pid: number;
45+
startTime: string;
46+
};
47+
48+
test('web shutdown cleanup reaps the exact daemon that survived graceful shutdown', async (t) => {
49+
const root = mkdtempSync('/tmp/agent-device-web-shutdown-cleanup-');
50+
const entryPath = path.join(root, 'src', 'daemon.ts');
51+
mkdirSync(path.dirname(entryPath), { recursive: true });
52+
writeFileSync(
53+
entryPath,
54+
[
55+
"process.on('SIGTERM', () => process.send?.('sigterm-ignored'));",
56+
"process.send?.('ready');",
57+
'setInterval(() => {}, 1000);',
58+
'',
59+
].join('\n'),
60+
'utf8',
61+
);
62+
const child = spawn(process.execPath, [entryPath], {
63+
stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
64+
});
65+
const daemonPid = child.pid ?? 0;
66+
assert.ok(daemonPid > 0, 'expected the fake daemon to have a pid');
67+
t.after(() => {
68+
if (isProcessAlive(daemonPid)) process.kill(daemonPid, 'SIGKILL');
69+
rmSync(root, { recursive: true, force: true });
70+
});
71+
72+
await new Promise<void>((resolve, reject) => {
73+
child.once('error', reject);
74+
child.once('message', (message) => {
75+
child.off('error', reject);
76+
assert.equal(message, 'ready');
77+
resolve();
78+
});
79+
});
80+
let ignoredSigterm = false;
81+
child.on('message', (message) => {
82+
if (message === 'sigterm-ignored') ignoredSigterm = true;
83+
});
84+
85+
const daemonStartTime = readProcessStartTime(daemonPid);
86+
assert.ok(daemonStartTime, 'expected the fake daemon to report a start time');
87+
await cleanupWebShutdownSmoke(
88+
{
89+
artifactDir: root,
90+
common: [],
91+
env: {},
92+
screenshotPath: path.join(root, 'unused.png'),
93+
server: createServer(),
94+
stepHistory: [],
95+
url: 'http://127.0.0.1',
96+
},
97+
{ pid: daemonPid, startTime: daemonStartTime },
98+
undefined,
99+
{ termTimeoutMs: 50, killTimeoutMs: 1_000 },
100+
);
101+
102+
assert.equal(
103+
ignoredSigterm,
104+
true,
105+
'expected cleanup to escalate after the child ignored SIGTERM',
106+
);
107+
assert.equal(isProcessAlive(daemonPid), false);
108+
});
109+
41110
type StepRecord = {
42111
step: string;
43112
command: string;
@@ -99,7 +168,7 @@ async function runWebShutdownSmoke(context: WebSmokeContext): Promise<void> {
99168
// Cleanup authority lives entirely in `finally`, driven by these two, so a failed assertion
100169
// above (including the very failure this test exists to catch: processes still alive when the
101170
// fix regresses) can never leave a daemon or a Chrome fleet running on the host afterward.
102-
let daemonPid: number | undefined;
171+
let daemonIdentity: WebShutdownDaemonIdentity | undefined;
103172
let status: AgentBrowserToolStatus | undefined;
104173
try {
105174
await runStep(context, 'set up managed web backend', ['web', 'setup', '--json']);
@@ -115,7 +184,10 @@ async function runWebShutdownSmoke(context: WebSmokeContext): Promise<void> {
115184
`expected the managed browser fleet to be running after open, found none: ${formatProcessSummary(before)}`,
116185
);
117186

118-
daemonPid = readDaemonPid(stateDir);
187+
const daemonPid = readDaemonPid(stateDir);
188+
const daemonStartTime = readProcessStartTime(daemonPid);
189+
assert.ok(daemonStartTime, 'expected the daemon process to report a start time');
190+
daemonIdentity = { pid: daemonPid, startTime: daemonStartTime };
119191
assert.equal(isProcessAlive(daemonPid), true, 'expected a live daemon before SIGTERM');
120192

121193
// The scenario #1868 is about: SIGTERM the daemon directly, the way an operator or an
@@ -138,7 +210,7 @@ async function runWebShutdownSmoke(context: WebSmokeContext): Promise<void> {
138210
// state-dir residue either, on top of the process-level proof above.
139211
await assertNoDaemonLeaks({ stateDir, daemonPids: [daemonPid], phase: 'after-shutdown' });
140212
} finally {
141-
await cleanupWebShutdownSmoke(context, daemonPid, status);
213+
await cleanupWebShutdownSmoke(context, daemonIdentity, status);
142214
}
143215
}
144216

@@ -150,16 +222,20 @@ async function runWebShutdownSmoke(context: WebSmokeContext): Promise<void> {
150222
// alongside.
151223
async function cleanupWebShutdownSmoke(
152224
context: WebSmokeContext,
153-
daemonPid: number | undefined,
225+
daemonIdentity: WebShutdownDaemonIdentity | undefined,
154226
status: AgentBrowserToolStatus | undefined,
227+
timeouts = {
228+
termTimeoutMs: WEB_SHUTDOWN_CLEANUP_TIMEOUT_MS,
229+
killTimeoutMs: WEB_SHUTDOWN_CLEANUP_TIMEOUT_MS,
230+
},
155231
): Promise<void> {
156232
const errors: unknown[] = [];
157-
if (daemonPid !== undefined) {
233+
if (daemonIdentity !== undefined) {
158234
try {
159-
await stopProcessForTakeover(daemonPid, {
160-
termTimeoutMs: WEB_SHUTDOWN_CLEANUP_TIMEOUT_MS,
161-
killTimeoutMs: WEB_SHUTDOWN_CLEANUP_TIMEOUT_MS,
162-
expectedStartTime: undefined,
235+
await stopProcessForTakeover(daemonIdentity.pid, {
236+
termTimeoutMs: timeouts.termTimeoutMs,
237+
killTimeoutMs: timeouts.killTimeoutMs,
238+
expectedStartTime: daemonIdentity.startTime,
163239
});
164240
} catch (error) {
165241
errors.push(error);

0 commit comments

Comments
 (0)