@@ -26,8 +26,8 @@ afterEach(() => {
2626 vi . unstubAllGlobals ( ) ;
2727} ) ;
2828
29- test ( 'prepareMetroRuntime stops a spawned Metro process when startup readiness times out' , async ( ) => {
30- const tempRoot = mkdtempForTestSync ( ' agent-device-metro-startup-cleanup-' ) ;
29+ function createMetroProject ( label : string ) : string {
30+ const tempRoot = mkdtempForTestSync ( ` agent-device-metro-startup-cleanup-${ label } -` ) ;
3131 const projectRoot = path . join ( tempRoot , 'project' ) ;
3232 fs . mkdirSync ( path . join ( projectRoot , 'node_modules' ) , { recursive : true } ) ;
3333 fs . writeFileSync (
@@ -40,39 +40,178 @@ test('prepareMetroRuntime stops a spawned Metro process when startup readiness t
4040 } ,
4141 } ) ,
4242 ) ;
43+ return projectRoot ;
44+ }
4345
44- vi . mocked ( runCmdDetached ) . mockReturnValue ( 987 ) ;
45- vi . mocked ( waitForProcessExit ) . mockResolvedValue ( true ) ;
46- const killSpy = vi . spyOn ( process , 'kill' ) . mockImplementation ( ( ) => true ) ;
46+ function metroPrepareInput ( projectRoot : string ) {
47+ return {
48+ projectRoot,
49+ publicBaseUrl : 'https://public.example.test' ,
50+ metroPort : 8081 ,
51+ reuseExisting : true ,
52+ installDependenciesIfNeeded : false ,
53+ probeTimeoutMs : 10 ,
54+ startupTimeoutMs : 30_000 ,
55+ env : { ...process . env , AGENT_DEVICE_DAEMON_AUTH_TOKEN : 'daemon-token' } ,
56+ } as const ;
57+ }
58+
59+ /** Starts a prepare whose Metro never becomes ready, letting readiness time out under fake timers. */
60+ async function runMetroPrepareThroughTimeout ( projectRoot : string ) : Promise < void > {
4761 const fetchMock = vi . fn ( ) . mockResolvedValue ( {
4862 ok : true ,
4963 status : 200 ,
5064 text : async ( ) => 'packager-status:not-running' ,
5165 } ) ;
5266 vi . stubGlobal ( 'fetch' , fetchMock ) ;
5367 vi . useFakeTimers ( ) ;
68+ const preparePromise = prepareMetroRuntime ( metroPrepareInput ( projectRoot ) ) ;
69+ const expectedFailure = assert . rejects (
70+ preparePromise ,
71+ / R e a c t N a t i v e d e v s e r v e r d i d n o t b e c o m e r e a d y a t h t t p : \/ \/ 1 2 7 \. 0 \. 0 \. 1 : 8 0 8 1 \/ s t a t u s w i t h i n 3 0 0 0 0 m s / ,
72+ ) ;
73+ await vi . advanceTimersByTimeAsync ( 30_000 ) ;
74+ await expectedFailure ;
75+ }
76+
77+ type KillSignal = 'SIGTERM' | 'SIGKILL' ;
78+
79+ function spyOnKill ( behavior : ( signal : KillSignal ) => boolean ) : {
80+ signals : Array < [ number , string ] > ;
81+ } {
82+ const signals : Array < [ number , string ] > = [ ] ;
83+ vi . spyOn ( process , 'kill' ) . mockImplementation ( ( ( pid : number , signal ?: string | number ) => {
84+ const name = ( signal ?? 'SIGTERM' ) as KillSignal ;
85+ signals . push ( [ pid , name ] ) ;
86+ return behavior ( name ) as true ;
87+ } ) as typeof process . kill ) ;
88+ return { signals } ;
89+ }
90+
91+ function killError ( code : string ) : NodeJS . ErrnoException {
92+ return Object . assign ( new Error ( `kill failed: ${ code } ` ) , { code } ) ;
93+ }
94+
95+ test ( 'prepareMetroRuntime stops a spawned Metro process when startup readiness times out' , async ( ) => {
96+ const projectRoot = createMetroProject ( 'timeout' ) ;
97+ vi . mocked ( runCmdDetached ) . mockReturnValue ( 987 ) ;
98+ vi . mocked ( waitForProcessExit ) . mockResolvedValue ( true ) ;
99+ const kill = spyOnKill ( ( ) => true ) ;
54100
55101 try {
56- const preparePromise = prepareMetroRuntime ( {
57- projectRoot,
58- publicBaseUrl : 'https://public.example.test' ,
59- metroPort : 8081 ,
60- reuseExisting : true ,
61- installDependenciesIfNeeded : false ,
62- probeTimeoutMs : 10 ,
63- startupTimeoutMs : 30_000 ,
64- env : { ...process . env , AGENT_DEVICE_DAEMON_AUTH_TOKEN : 'daemon-token' } ,
102+ await runMetroPrepareThroughTimeout ( projectRoot ) ;
103+ assert . equal ( vi . mocked ( runCmdDetached ) . mock . calls . length , 1 ) ;
104+ assert . deepEqual ( kill . signals , [ [ 987 , 'SIGTERM' ] ] ) ;
105+ assert . deepEqual ( vi . mocked ( waitForProcessExit ) . mock . calls , [ [ 987 , 1_000 ] ] ) ;
106+ } finally {
107+ fs . rmSync ( path . dirname ( projectRoot ) , { recursive : true , force : true } ) ;
108+ }
109+ } ) ;
110+
111+ test ( 'stopSpawnedMetroProcess gives up cleanup (best effort) when SIGTERM throws ESRCH or EPERM' , async ( ) => {
112+ for ( const code of [ 'ESRCH' , 'EPERM' ] ) {
113+ vi . clearAllMocks ( ) ;
114+ const projectRoot = createMetroProject ( `term-${ code } ` ) ;
115+ vi . mocked ( runCmdDetached ) . mockReturnValue ( 987 ) ;
116+ const kill = spyOnKill ( ( ) => {
117+ throw killError ( code ) ;
65118 } ) ;
66119
67- const expectedFailure = assert . rejects (
68- preparePromise ,
69- / R e a c t N a t i v e d e v s e r v e r d i d n o t b e c o m e r e a d y a t h t t p : \/ \/ 1 2 7 \. 0 \. 0 \. 1 : 8 0 8 1 \/ s t a t u s w i t h i n 3 0 0 0 0 m s / ,
70- ) ;
71- await vi . advanceTimersByTimeAsync ( 30_000 ) ;
72- await expectedFailure ;
73- assert . equal ( vi . mocked ( runCmdDetached ) . mock . calls . length , 1 ) ;
74- assert . deepEqual ( killSpy . mock . calls [ 0 ] , [ 987 , 'SIGTERM' ] ) ;
120+ try {
121+ await runMetroPrepareThroughTimeout ( projectRoot ) ;
122+ assert . deepEqual ( kill . signals , [ [ 987 , 'SIGTERM' ] ] , `${ code } on SIGTERM must stop cleanup` ) ;
123+ assert . equal ( vi . mocked ( waitForProcessExit ) . mock . calls . length , 0 , code ) ;
124+ } finally {
125+ fs . rmSync ( path . dirname ( projectRoot ) , { recursive : true , force : true } ) ;
126+ }
127+ }
128+ } ) ;
129+
130+ test ( 'stopSpawnedMetroProcess escalates to SIGKILL when SIGTERM does not exit the process' , async ( ) => {
131+ const projectRoot = createMetroProject ( 'escalate' ) ;
132+ vi . mocked ( runCmdDetached ) . mockReturnValue ( 987 ) ;
133+ vi . mocked ( waitForProcessExit ) . mockResolvedValue ( false ) ;
134+ const kill = spyOnKill ( ( ) => true ) ;
135+
136+ try {
137+ await runMetroPrepareThroughTimeout ( projectRoot ) ;
138+ assert . deepEqual ( kill . signals , [
139+ [ 987 , 'SIGTERM' ] ,
140+ [ 987 , 'SIGKILL' ] ,
141+ ] ) ;
142+ assert . deepEqual ( vi . mocked ( waitForProcessExit ) . mock . calls , [
143+ [ 987 , 1_000 ] ,
144+ [ 987 , 1_000 ] ,
145+ ] ) ;
146+ } finally {
147+ fs . rmSync ( path . dirname ( projectRoot ) , { recursive : true , force : true } ) ;
148+ }
149+ } ) ;
150+
151+ test ( 'stopSpawnedMetroProcess gives up cleanup (best effort) when SIGKILL throws ESRCH or EPERM' , async ( ) => {
152+ for ( const code of [ 'ESRCH' , 'EPERM' ] ) {
153+ vi . clearAllMocks ( ) ;
154+ const projectRoot = createMetroProject ( `kill-${ code } ` ) ;
155+ vi . mocked ( runCmdDetached ) . mockReturnValue ( 987 ) ;
156+ vi . mocked ( waitForProcessExit ) . mockResolvedValue ( false ) ;
157+ const kill = spyOnKill ( ( signal ) => {
158+ if ( signal === 'SIGKILL' ) throw killError ( code ) ;
159+ return true ;
160+ } ) ;
161+
162+ try {
163+ await runMetroPrepareThroughTimeout ( projectRoot ) ;
164+ assert . deepEqual ( kill . signals , [
165+ [ 987 , 'SIGTERM' ] ,
166+ [ 987 , 'SIGKILL' ] ,
167+ ] ) ;
168+ assert . deepEqual (
169+ vi . mocked ( waitForProcessExit ) . mock . calls ,
170+ [ [ 987 , 1_000 ] ] ,
171+ `${ code } on SIGKILL means the process is already gone; no second exit wait` ,
172+ ) ;
173+ } finally {
174+ fs . rmSync ( path . dirname ( projectRoot ) , { recursive : true , force : true } ) ;
175+ }
176+ }
177+ } ) ;
178+
179+ // The stop call site is `stopSpawnedMetroProcess(pid).catch(() => {})`, so an unexpected kill
180+ // error is swallowed and the only observable outcome is the readiness timeout, exactly as for
181+ // the ESRCH/EPERM give-up cases. These pin the escalation attempt, not a rethrow.
182+ test ( 'prepareMetroRuntime reports the readiness timeout when the escalated SIGKILL is refused with EACCES' , async ( ) => {
183+ const projectRoot = createMetroProject ( 'kill-eacces' ) ;
184+ vi . mocked ( runCmdDetached ) . mockReturnValue ( 987 ) ;
185+ vi . mocked ( waitForProcessExit ) . mockResolvedValue ( false ) ;
186+ const kill = spyOnKill ( ( signal ) => {
187+ if ( signal === 'SIGKILL' ) throw killError ( 'EACCES' ) ;
188+ return true ;
189+ } ) ;
190+
191+ try {
192+ await runMetroPrepareThroughTimeout ( projectRoot ) ;
193+ assert . deepEqual ( kill . signals , [
194+ [ 987 , 'SIGTERM' ] ,
195+ [ 987 , 'SIGKILL' ] ,
196+ ] ) ;
197+ assert . deepEqual ( vi . mocked ( waitForProcessExit ) . mock . calls , [ [ 987 , 1_000 ] ] ) ;
198+ } finally {
199+ fs . rmSync ( path . dirname ( projectRoot ) , { recursive : true , force : true } ) ;
200+ }
201+ } ) ;
202+
203+ test ( 'prepareMetroRuntime reports the readiness timeout when SIGTERM is refused with EACCES' , async ( ) => {
204+ const projectRoot = createMetroProject ( 'term-eacces' ) ;
205+ vi . mocked ( runCmdDetached ) . mockReturnValue ( 987 ) ;
206+ const kill = spyOnKill ( ( ) => {
207+ throw killError ( 'EACCES' ) ;
208+ } ) ;
209+
210+ try {
211+ await runMetroPrepareThroughTimeout ( projectRoot ) ;
212+ assert . deepEqual ( kill . signals , [ [ 987 , 'SIGTERM' ] ] ) ;
213+ assert . equal ( vi . mocked ( waitForProcessExit ) . mock . calls . length , 0 ) ;
75214 } finally {
76- fs . rmSync ( tempRoot , { recursive : true , force : true } ) ;
215+ fs . rmSync ( path . dirname ( projectRoot ) , { recursive : true , force : true } ) ;
77216 }
78217} ) ;
0 commit comments