@@ -77,10 +77,19 @@ export type QaGatewayChildCommand = {
7777 usePackagedPlugins ?: boolean ;
7878} ;
7979
80+ export type QaGatewayChildListeningContext = {
81+ attempt : number ;
82+ baseUrl : string ;
83+ wsUrl : string ;
84+ token : string ;
85+ configPath : string ;
86+ runtimeEnv : NodeJS . ProcessEnv ;
87+ } ;
88+
8089async function getFreePort ( ) {
8190 return await new Promise < number > ( ( resolve , reject ) => {
8291 const server = net . createServer ( ) ;
83- server . once ( "error" , reject ) ;
92+ server . once ( "error" , ( error ) => reject ( error ) ) ;
8493 server . listen ( 0 , "127.0.0.1" , ( ) => {
8594 const address = server . address ( ) ;
8695 if ( ! address || typeof address === "string" ) {
@@ -319,6 +328,23 @@ async function fetchLocalGatewayHealth(params: {
319328 }
320329}
321330
331+ async function fetchLocalGatewayListening ( baseUrl : string ) : Promise < boolean > {
332+ const { release } = await fetchWithSsrFGuard ( {
333+ url : `${ baseUrl } /healthz` ,
334+ init : {
335+ method : "HEAD" ,
336+ headers : {
337+ connection : "close" ,
338+ } ,
339+ signal : AbortSignal . timeout ( 2_000 ) ,
340+ } ,
341+ policy : { allowPrivateNetwork : true } ,
342+ auditContext : "qa-lab-gateway-child-listening" ,
343+ } ) ;
344+ await release ( ) ;
345+ return true ;
346+ }
347+
322348async function waitForQaGatewayRestartBoundary ( params : {
323349 logs : ( ) => string ;
324350 offset : number ;
@@ -577,6 +603,47 @@ async function waitForGatewayReady(params: {
577603 ) ;
578604}
579605
606+ async function waitForGatewayListening ( params : {
607+ baseUrl : string ;
608+ logs : ( ) => string ;
609+ child : {
610+ exitCode : number | null ;
611+ signalCode : NodeJS . Signals | null ;
612+ } ;
613+ getSpawnError ?: ( ) => unknown ;
614+ timeoutMs ?: number ;
615+ } ) {
616+ const startedAt = Date . now ( ) ;
617+ while ( Date . now ( ) - startedAt < ( params . timeoutMs ?? 60_000 ) ) {
618+ const spawnError = params . getSpawnError ?.( ) ;
619+ if ( spawnError ) {
620+ throw new QaSuiteInfraError (
621+ "gateway_startup_unhealthy" ,
622+ `gateway failed to spawn: ${ formatErrorMessage ( spawnError ) } \n${ params . logs ( ) } ` ,
623+ { cause : spawnError } ,
624+ ) ;
625+ }
626+ if ( params . child . exitCode !== null || params . child . signalCode !== null ) {
627+ throw new QaSuiteInfraError (
628+ "gateway_startup_unhealthy" ,
629+ `gateway exited before listening (exitCode=${ String ( params . child . exitCode ) } , signal=${ String ( params . child . signalCode ) } ):\n${ params . logs ( ) } ` ,
630+ ) ;
631+ }
632+ try {
633+ if ( await fetchLocalGatewayListening ( params . baseUrl ) ) {
634+ return ;
635+ }
636+ } catch {
637+ // retry until the HTTP listener accepts requests
638+ }
639+ await sleep ( 100 ) ;
640+ }
641+ throw new QaSuiteInfraError (
642+ "gateway_startup_unhealthy" ,
643+ `gateway failed to listen before timeout:\n${ params . logs ( ) } ` ,
644+ ) ;
645+ }
646+
580647function isRetryableRpcStartupError ( error : unknown ) {
581648 const details = formatErrorMessage ( error ) ;
582649 return (
@@ -616,6 +683,7 @@ export async function startQaGatewayChild(params: {
616683 enabledPluginIds ?: string [ ] ;
617684 forwardHostHome ?: boolean ;
618685 mockAuthAgentIds ?: readonly string [ ] ;
686+ onListening ?: ( context : QaGatewayChildListeningContext ) => Promise < void > | void ;
619687 mutateConfig ?: ( cfg : OpenClawConfig ) => OpenClawConfig ;
620688 runtimeEnvPatch ?: NodeJS . ProcessEnv ;
621689} ) {
@@ -836,6 +904,21 @@ export async function startQaGatewayChild(params: {
836904 const getAttemptSpawnError = monitorQaGatewayChildSpawnError ( attemptChild , output ) ;
837905
838906 try {
907+ await waitForGatewayListening ( {
908+ baseUrl,
909+ logs,
910+ child : attemptChild ,
911+ getSpawnError : getAttemptSpawnError ,
912+ timeoutMs : 120_000 ,
913+ } ) ;
914+ await params . onListening ?.( {
915+ attempt,
916+ baseUrl,
917+ wsUrl,
918+ token : gatewayToken ,
919+ configPath,
920+ runtimeEnv : env ,
921+ } ) ;
839922 await waitForGatewayReady ( {
840923 baseUrl,
841924 logs,
0 commit comments