@@ -3,7 +3,7 @@ import os from 'node:os';
33import path from 'node:path' ;
44import { fileURLToPath } from 'node:url' ;
55import { AppError } from '../../utils/errors.ts' ;
6- import { runCmd , runCmdStreaming , type ExecResult } from '../../utils/exec.ts' ;
6+ import { runCmd , runCmdStreaming , runCmdBackground , type ExecResult , type ExecBackgroundResult } from '../../utils/exec.ts' ;
77import { withRetry } from '../../utils/retry.ts' ;
88import type { DeviceInfo } from '../../utils/device.ts' ;
99import net from 'node:net' ;
@@ -46,9 +46,30 @@ export type RunnerSession = {
4646 xctestrunPath : string ;
4747 jsonPath : string ;
4848 testPromise : Promise < ExecResult > ;
49+ child : ExecBackgroundResult [ 'child' ] ;
50+ ready : boolean ;
4951} ;
5052
5153const runnerSessions = new Map < string , RunnerSession > ( ) ;
54+ const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs (
55+ process . env . AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS ,
56+ 120_000 ,
57+ 5_000 ,
58+ ) ;
59+ const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs (
60+ process . env . AGENT_DEVICE_RUNNER_COMMAND_TIMEOUT_MS ,
61+ 15_000 ,
62+ 1_000 ,
63+ ) ;
64+ const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000 ;
65+ const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000 ;
66+
67+ function resolveTimeoutMs ( raw : string | undefined , fallback : number , min : number ) : number {
68+ if ( ! raw ) return fallback ;
69+ const parsed = Number ( raw ) ;
70+ if ( ! Number . isFinite ( parsed ) ) return fallback ;
71+ return Math . max ( min , Math . floor ( parsed ) ) ;
72+ }
5273
5374export type RunnerSnapshotNode = {
5475 index : number ;
@@ -87,29 +108,14 @@ async function executeRunnerCommand(
87108
88109 try {
89110 const session = await ensureRunnerSession ( device , options ) ;
90- const response = await waitForRunner ( device , session . port , command , options . logPath ) ;
91- const text = await response . text ( ) ;
92-
93- let json : any = { } ;
94- try {
95- json = JSON . parse ( text ) ;
96- } catch {
97- throw new AppError ( 'COMMAND_FAILED' , 'Invalid runner response' , { text } ) ;
98- }
99-
100- if ( ! json . ok ) {
101- throw new AppError ( 'COMMAND_FAILED' , json . error ?. message ?? 'Runner error' , {
102- runner : json ,
103- xcodebuild : {
104- exitCode : 1 ,
105- stdout : '' ,
106- stderr : '' ,
107- } ,
108- logPath : options . logPath ,
109- } ) ;
110- }
111-
112- return json . data ?? { } ;
111+ const timeoutMs = session . ready ? RUNNER_COMMAND_TIMEOUT_MS : RUNNER_STARTUP_TIMEOUT_MS ;
112+ return await executeRunnerCommandWithSession (
113+ device ,
114+ session ,
115+ command ,
116+ options . logPath ,
117+ timeoutMs ,
118+ ) ;
113119 } catch ( err ) {
114120 const appErr = err instanceof AppError ? err : new AppError ( 'COMMAND_FAILED' , String ( err ) ) ;
115121 if (
@@ -119,46 +125,79 @@ async function executeRunnerCommand(
119125 ) {
120126 await stopIosRunnerSession ( device . id ) ;
121127 const session = await ensureRunnerSession ( device , options ) ;
122- const response = await waitForRunner ( device , session . port , command , options . logPath ) ;
123- const text = await response . text ( ) ;
124- let json : any = { } ;
125- try {
126- json = JSON . parse ( text ) ;
127- } catch {
128- throw new AppError ( 'COMMAND_FAILED' , 'Invalid runner response' , { text } ) ;
129- }
130- if ( ! json . ok ) {
131- throw new AppError ( 'COMMAND_FAILED' , json . error ?. message ?? 'Runner error' , {
132- runner : json ,
133- xcodebuild : {
134- exitCode : 1 ,
135- stdout : '' ,
136- stderr : '' ,
137- } ,
138- logPath : options . logPath ,
139- } ) ;
140- }
141- return json . data ?? { } ;
128+ const response = await waitForRunner (
129+ session . device ,
130+ session . port ,
131+ command ,
132+ options . logPath ,
133+ RUNNER_STARTUP_TIMEOUT_MS ,
134+ ) ;
135+ return await parseRunnerResponse ( response , session , options . logPath ) ;
142136 }
143137 throw err ;
144138 }
145139}
146140
141+ async function executeRunnerCommandWithSession (
142+ device : DeviceInfo ,
143+ session : RunnerSession ,
144+ command : RunnerCommand ,
145+ logPath : string | undefined ,
146+ timeoutMs : number ,
147+ ) : Promise < Record < string , unknown > > {
148+ const response = await waitForRunner ( device , session . port , command , logPath , timeoutMs ) ;
149+ return await parseRunnerResponse ( response , session , logPath ) ;
150+ }
151+
152+ async function parseRunnerResponse (
153+ response : Response ,
154+ session : RunnerSession ,
155+ logPath ?: string ,
156+ ) : Promise < Record < string , unknown > > {
157+ const text = await response . text ( ) ;
158+ let json : any = { } ;
159+ try {
160+ json = JSON . parse ( text ) ;
161+ } catch {
162+ throw new AppError ( 'COMMAND_FAILED' , 'Invalid runner response' , { text } ) ;
163+ }
164+ if ( ! json . ok ) {
165+ throw new AppError ( 'COMMAND_FAILED' , json . error ?. message ?? 'Runner error' , {
166+ runner : json ,
167+ xcodebuild : {
168+ exitCode : 1 ,
169+ stdout : '' ,
170+ stderr : '' ,
171+ } ,
172+ logPath,
173+ } ) ;
174+ }
175+ session . ready = true ;
176+ return json . data ?? { } ;
177+ }
178+
147179export async function stopIosRunnerSession ( deviceId : string ) : Promise < void > {
148180 const session = runnerSessions . get ( deviceId ) ;
149181 if ( ! session ) return ;
150182 try {
151183 await waitForRunner ( session . device , session . port , {
152184 command : 'shutdown' ,
153- } as RunnerCommand ) ;
185+ } as RunnerCommand , undefined , RUNNER_SHUTDOWN_TIMEOUT_MS ) ;
154186 } catch {
155- // ignore
187+ // Runner not responsive — send SIGTERM so we don't hang on testPromise
188+ await killRunnerProcessTree ( session . child . pid , 'SIGTERM' ) ;
156189 }
157190 try {
158- await session . testPromise ;
191+ // Bound the wait so we never hang if xcodebuild refuses to exit
192+ await Promise . race ( [
193+ session . testPromise ,
194+ new Promise < void > ( ( resolve ) => setTimeout ( resolve , RUNNER_STOP_WAIT_TIMEOUT_MS ) ) ,
195+ ] ) ;
159196 } catch {
160197 // ignore
161198 }
199+ // Force-kill if still alive (harmless if already exited)
200+ await killRunnerProcessTree ( session . child . pid , 'SIGKILL' ) ;
162201 cleanupTempFile ( session . xctestrunPath ) ;
163202 cleanupTempFile ( session . jsonPath ) ;
164203 runnerSessions . delete ( deviceId ) ;
@@ -183,7 +222,7 @@ async function ensureRunnerSession(
183222 { AGENT_DEVICE_RUNNER_PORT : String ( port ) } ,
184223 `session-${ device . id } -${ port } ` ,
185224 ) ;
186- const testPromise = runCmdStreaming (
225+ const { child , wait : testPromise } = runCmdBackground (
187226 'xcodebuild' ,
188227 [
189228 'test-without-building' ,
@@ -201,16 +240,16 @@ async function ensureRunnerSession(
201240 `platform=iOS Simulator,id=${ device . id } ` ,
202241 ] ,
203242 {
204- onStdoutChunk : ( chunk ) => {
205- logChunk ( chunk , options . logPath , options . traceLogPath , options . verbose ) ;
206- } ,
207- onStderrChunk : ( chunk ) => {
208- logChunk ( chunk , options . logPath , options . traceLogPath , options . verbose ) ;
209- } ,
210243 allowFailure : true ,
211244 env : { ...process . env , AGENT_DEVICE_RUNNER_PORT : String ( port ) } ,
212245 } ,
213246 ) ;
247+ child . stdout ?. on ( 'data' , ( chunk : string ) => {
248+ logChunk ( chunk , options . logPath , options . traceLogPath , options . verbose ) ;
249+ } ) ;
250+ child . stderr ?. on ( 'data' , ( chunk : string ) => {
251+ logChunk ( chunk , options . logPath , options . traceLogPath , options . verbose ) ;
252+ } ) ;
214253
215254 const session : RunnerSession = {
216255 device,
@@ -219,11 +258,31 @@ async function ensureRunnerSession(
219258 xctestrunPath,
220259 jsonPath,
221260 testPromise,
261+ child,
262+ ready : false ,
222263 } ;
223264 runnerSessions . set ( device . id , session ) ;
224265 return session ;
225266}
226267
268+ async function killRunnerProcessTree (
269+ pid : number | undefined ,
270+ signal : 'SIGTERM' | 'SIGKILL' ,
271+ ) : Promise < void > {
272+ if ( ! pid || pid <= 0 ) return ;
273+ try {
274+ process . kill ( pid , signal ) ;
275+ } catch {
276+ // ignore
277+ }
278+ const pkillSignal = signal === 'SIGTERM' ? 'TERM' : 'KILL' ;
279+ try {
280+ await runCmd ( 'pkill' , [ `-${ pkillSignal } ` , '-P' , String ( pid ) ] , { allowFailure : true } ) ;
281+ } catch {
282+ // ignore
283+ }
284+ }
285+
227286
228287async function ensureXctestrun (
229288 udid : string ,
@@ -364,10 +423,11 @@ async function waitForRunner(
364423 port : number ,
365424 command : RunnerCommand ,
366425 logPath ?: string ,
426+ timeoutMs : number = RUNNER_STARTUP_TIMEOUT_MS ,
367427) : Promise < Response > {
368428 const start = Date . now ( ) ;
369429 let lastError : unknown = null ;
370- while ( Date . now ( ) - start < 15000 ) {
430+ while ( Date . now ( ) - start < timeoutMs ) {
371431 try {
372432 const response = await fetch ( `http://127.0.0.1:${ port } /command` , {
373433 method : 'POST' ,
0 commit comments