11import 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' ;
34import { createServer , type Server } from 'node:http' ;
45import path from 'node:path' ;
56import 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.
3941const 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+
41110type 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.
151223async 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