@@ -4,7 +4,7 @@ import path from 'node:path';
44import { fileURLToPath } from 'node:url' ;
55import { AppError } from '../../utils/errors.ts' ;
66import { runCmd , runCmdStreaming , runCmdBackground , type ExecResult , type ExecBackgroundResult } from '../../utils/exec.ts' ;
7- import { Deadline , retryWithPolicy , withRetry } from '../../utils/retry.ts' ;
7+ import { Deadline , isEnvTruthy , retryWithPolicy , withRetry } from '../../utils/retry.ts' ;
88import type { DeviceInfo } from '../../utils/device.ts' ;
99import net from 'node:net' ;
1010import { bootFailureHint , classifyBootFailure } from '../boot-diagnostics.ts' ;
@@ -65,8 +65,34 @@ const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
6565 15_000 ,
6666 1_000 ,
6767) ;
68+ const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = resolveTimeoutMs (
69+ process . env . AGENT_DEVICE_RUNNER_CONNECT_ATTEMPT_INTERVAL_MS ,
70+ 250 ,
71+ 50 ,
72+ ) ;
73+ const RUNNER_CONNECT_RETRY_BASE_DELAY_MS = resolveTimeoutMs (
74+ process . env . AGENT_DEVICE_RUNNER_CONNECT_RETRY_BASE_DELAY_MS ,
75+ 100 ,
76+ 10 ,
77+ ) ;
78+ const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = resolveTimeoutMs (
79+ process . env . AGENT_DEVICE_RUNNER_CONNECT_RETRY_MAX_DELAY_MS ,
80+ 500 ,
81+ 10 ,
82+ ) ;
83+ const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = resolveTimeoutMs (
84+ process . env . AGENT_DEVICE_RUNNER_CONNECT_REQUEST_TIMEOUT_MS ,
85+ 1_000 ,
86+ 50 ,
87+ ) ;
88+ const RUNNER_DEVICE_INFO_TIMEOUT_MS = resolveTimeoutMs (
89+ process . env . AGENT_DEVICE_IOS_DEVICE_INFO_TIMEOUT_MS ,
90+ 10_000 ,
91+ 500 ,
92+ ) ;
6893const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000 ;
6994const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000 ;
95+ const RUNNER_DERIVED_ROOT = path . join ( os . homedir ( ) , '.agent-device' , 'ios-runner' ) ;
7096
7197function resolveTimeoutMs ( raw : string | undefined , fallback : number , min : number ) : number {
7298 if ( ! raw ) return fallback ;
@@ -294,6 +320,7 @@ async function ensureXctestrun(
294320) : Promise < string > {
295321 const derived = resolveRunnerDerivedPath ( device . kind ) ;
296322 if ( shouldCleanDerived ( ) ) {
323+ assertSafeDerivedCleanup ( derived ) ;
297324 try {
298325 fs . rmSync ( derived , { recursive : true , force : true } ) ;
299326 } catch {
@@ -364,8 +391,7 @@ function resolveRunnerDerivedPath(kind: DeviceInfo['kind']): string {
364391 if ( override ) {
365392 return path . resolve ( override ) ;
366393 }
367- const base = path . join ( os . homedir ( ) , '.agent-device' , 'ios-runner' ) ;
368- return path . join ( base , 'derived' , kind ) ;
394+ return path . join ( RUNNER_DERIVED_ROOT , 'derived' , kind ) ;
369395}
370396
371397export function resolveRunnerDestination ( device : DeviceInfo ) : string {
@@ -509,9 +535,54 @@ function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
509535}
510536
511537function shouldCleanDerived ( ) : boolean {
512- const value = process . env . AGENT_DEVICE_IOS_CLEAN_DERIVED ;
513- if ( ! value ) return false ;
514- return [ '1' , 'true' , 'yes' , 'on' ] . includes ( value . toLowerCase ( ) ) ;
538+ return isEnvTruthy ( process . env . AGENT_DEVICE_IOS_CLEAN_DERIVED ) ;
539+ }
540+
541+ export function assertSafeDerivedCleanup (
542+ derivedPath : string ,
543+ env : NodeJS . ProcessEnv = process . env ,
544+ ) : void {
545+ const override = env . AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH ?. trim ( ) ;
546+ if ( ! override ) {
547+ return ;
548+ }
549+ if ( isCleanupOverrideAllowed ( env ) ) {
550+ return ;
551+ }
552+ throw new AppError (
553+ 'COMMAND_FAILED' ,
554+ 'Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically' ,
555+ {
556+ derivedPath,
557+ hint : 'Unset AGENT_DEVICE_IOS_CLEAN_DERIVED, or set AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN=1 if you trust this path.' ,
558+ } ,
559+ ) ;
560+ }
561+
562+ function isCleanupOverrideAllowed ( env : NodeJS . ProcessEnv = process . env ) : boolean {
563+ return isEnvTruthy ( env . AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN ) ;
564+ }
565+
566+ function buildRunnerConnectError ( params : {
567+ port : number ;
568+ endpoints : string [ ] ;
569+ logPath ?: string ;
570+ lastError : unknown ;
571+ } ) : AppError {
572+ const { port, endpoints, logPath, lastError } = params ;
573+ const message = 'Runner did not accept connection' ;
574+ return new AppError ( 'COMMAND_FAILED' , message , {
575+ port,
576+ endpoints,
577+ logPath,
578+ lastError : lastError ? String ( lastError ) : undefined ,
579+ reason : classifyBootFailure ( {
580+ error : lastError ,
581+ message,
582+ context : { platform : 'ios' , phase : 'connect' } ,
583+ } ) ,
584+ hint : bootFailureHint ( 'IOS_RUNNER_CONNECT_TIMEOUT' ) ,
585+ } ) ;
515586}
516587
517588async function waitForRunner (
@@ -521,26 +592,39 @@ async function waitForRunner(
521592 logPath ?: string ,
522593 timeoutMs : number = RUNNER_STARTUP_TIMEOUT_MS ,
523594) : Promise < Response > {
524- let endpoints = await resolveRunnerCommandEndpoints ( device , port ) ;
525- let lastError : unknown = null ;
526595 const deadline = Deadline . fromTimeoutMs ( timeoutMs ) ;
527- const maxAttempts = Math . max ( 1 , Math . ceil ( timeoutMs / 250 ) ) ;
596+ let endpoints = await resolveRunnerCommandEndpoints ( device , port , deadline . remainingMs ( ) ) ;
597+ let lastError : unknown = null ;
598+ const maxAttempts = Math . max ( 1 , Math . ceil ( timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS ) ) ;
528599 try {
529600 return await retryWithPolicy (
530- async ( ) => {
601+ async ( { deadline : attemptDeadline } ) => {
602+ if ( attemptDeadline ?. isExpired ( ) ) {
603+ throw new AppError ( 'COMMAND_FAILED' , 'Runner connection deadline exceeded' , {
604+ port,
605+ timeoutMs,
606+ } ) ;
607+ }
531608 if ( device . kind === 'device' ) {
532- endpoints = await resolveRunnerCommandEndpoints ( device , port ) ;
609+ endpoints = await resolveRunnerCommandEndpoints ( device , port , attemptDeadline ?. remainingMs ( ) ) ;
533610 }
534611 for ( const endpoint of endpoints ) {
535612 try {
613+ const remainingMs = attemptDeadline ?. remainingMs ( ) ?? timeoutMs ;
614+ if ( remainingMs <= 0 ) {
615+ throw new AppError ( 'COMMAND_FAILED' , 'Runner connection deadline exceeded' , {
616+ port,
617+ timeoutMs,
618+ } ) ;
619+ }
536620 const response = await fetchWithTimeout (
537621 endpoint ,
538622 {
539623 method : 'POST' ,
540624 headers : { 'Content-Type' : 'application/json' } ,
541625 body : JSON . stringify ( command ) ,
542626 } ,
543- 1_000 ,
627+ Math . min ( RUNNER_CONNECT_REQUEST_TIMEOUT_MS , remainingMs ) ,
544628 ) ;
545629 return response ;
546630 } catch ( err ) {
@@ -555,8 +639,8 @@ async function waitForRunner(
555639 } ,
556640 {
557641 maxAttempts,
558- baseDelayMs : 100 ,
559- maxDelayMs : 500 ,
642+ baseDelayMs : RUNNER_CONNECT_RETRY_BASE_DELAY_MS ,
643+ maxDelayMs : RUNNER_CONNECT_RETRY_MAX_DELAY_MS ,
560644 jitter : 0.2 ,
561645 shouldRetry : ( ) => true ,
562646 } ,
@@ -569,33 +653,27 @@ async function waitForRunner(
569653 }
570654
571655 if ( device . kind === 'simulator' ) {
572- const simResponse = await postCommandViaSimulator ( device . id , port , command ) ;
656+ const remainingMs = deadline . remainingMs ( ) ;
657+ if ( remainingMs <= 0 ) {
658+ throw buildRunnerConnectError ( { port, endpoints, logPath, lastError } ) ;
659+ }
660+ const simResponse = await postCommandViaSimulator ( device . id , port , command , remainingMs ) ;
573661 return new Response ( simResponse . body , { status : simResponse . status } ) ;
574662 }
575663
576- throw new AppError ( 'COMMAND_FAILED' , 'Runner did not accept connection' , {
577- port,
578- endpoints,
579- logPath,
580- lastError : lastError ? String ( lastError ) : undefined ,
581- reason : classifyBootFailure ( {
582- error : lastError ,
583- message : 'Runner did not accept connection' ,
584- context : { platform : 'ios' , phase : 'connect' } ,
585- } ) ,
586- hint : bootFailureHint ( 'IOS_RUNNER_CONNECT_TIMEOUT' ) ,
587- } ) ;
664+ throw buildRunnerConnectError ( { port, endpoints, logPath, lastError } ) ;
588665}
589666
590667async function resolveRunnerCommandEndpoints (
591668 device : DeviceInfo ,
592669 port : number ,
670+ timeoutBudgetMs ?: number ,
593671) : Promise < string [ ] > {
594672 const endpoints = [ `http://127.0.0.1:${ port } /command` ] ;
595673 if ( device . kind !== 'device' ) {
596674 return endpoints ;
597675 }
598- const tunnelIp = await resolveDeviceTunnelIp ( device . id ) ;
676+ const tunnelIp = await resolveDeviceTunnelIp ( device . id , timeoutBudgetMs ) ;
599677 if ( tunnelIp ) {
600678 endpoints . unshift ( `http://[${ tunnelIp } ]:${ port } /command` ) ;
601679 }
@@ -616,12 +694,19 @@ async function fetchWithTimeout(
616694 }
617695}
618696
619- async function resolveDeviceTunnelIp ( deviceId : string ) : Promise < string | null > {
697+ async function resolveDeviceTunnelIp ( deviceId : string , timeoutBudgetMs ?: number ) : Promise < string | null > {
698+ if ( typeof timeoutBudgetMs === 'number' && timeoutBudgetMs <= 0 ) {
699+ return null ;
700+ }
701+ const timeoutMs = typeof timeoutBudgetMs === 'number'
702+ ? Math . max ( 1 , Math . min ( RUNNER_DEVICE_INFO_TIMEOUT_MS , timeoutBudgetMs ) )
703+ : RUNNER_DEVICE_INFO_TIMEOUT_MS ;
620704 const jsonPath = path . join (
621705 os . tmpdir ( ) ,
622706 `agent-device-devicectl-info-${ process . pid } -${ Date . now ( ) } .json` ,
623707 ) ;
624708 try {
709+ const devicectlTimeoutSeconds = Math . max ( 1 , Math . ceil ( timeoutMs / 1000 ) ) ;
625710 const result = await runCmd (
626711 'xcrun' ,
627712 [
@@ -634,9 +719,9 @@ async function resolveDeviceTunnelIp(deviceId: string): Promise<string | null> {
634719 '--json-output' ,
635720 jsonPath ,
636721 '--timeout' ,
637- '10' ,
722+ String ( devicectlTimeoutSeconds ) ,
638723 ] ,
639- { allowFailure : true } ,
724+ { allowFailure : true , timeoutMs } ,
640725 ) ;
641726 if ( result . exitCode !== 0 || ! fs . existsSync ( jsonPath ) ) {
642727 return null ;
@@ -667,6 +752,7 @@ async function postCommandViaSimulator(
667752 udid : string ,
668753 port : number ,
669754 command : RunnerCommand ,
755+ timeoutMs : number ,
670756) : Promise < { status : number ; body : string } > {
671757 const payload = JSON . stringify ( command ) ;
672758 const result = await runCmd (
@@ -685,7 +771,7 @@ async function postCommandViaSimulator(
685771 payload ,
686772 `http://127.0.0.1:${ port } /command` ,
687773 ] ,
688- { allowFailure : true } ,
774+ { allowFailure : true , timeoutMs } ,
689775 ) ;
690776 const body = result . stdout as string ;
691777 if ( result . exitCode !== 0 ) {
0 commit comments