@@ -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" ) {
@@ -317,6 +326,23 @@ async function fetchLocalGatewayHealth(params: {
317326 }
318327}
319328
329+ async function fetchLocalGatewayListening ( baseUrl : string ) : Promise < boolean > {
330+ const { release } = await fetchWithSsrFGuard ( {
331+ url : `${ baseUrl } /healthz` ,
332+ init : {
333+ method : "HEAD" ,
334+ headers : {
335+ connection : "close" ,
336+ } ,
337+ signal : AbortSignal . timeout ( 2_000 ) ,
338+ } ,
339+ policy : { allowPrivateNetwork : true } ,
340+ auditContext : "qa-lab-gateway-child-listening" ,
341+ } ) ;
342+ await release ( ) ;
343+ return true ;
344+ }
345+
320346async function waitForQaGatewayRestartBoundary ( params : {
321347 logs : ( ) => string ;
322348 offset : number ;
@@ -575,6 +601,47 @@ async function waitForGatewayReady(params: {
575601 ) ;
576602}
577603
604+ async function waitForGatewayListening ( params : {
605+ baseUrl : string ;
606+ logs : ( ) => string ;
607+ child : {
608+ exitCode : number | null ;
609+ signalCode : NodeJS . Signals | null ;
610+ } ;
611+ getSpawnError ?: ( ) => unknown ;
612+ timeoutMs ?: number ;
613+ } ) {
614+ const startedAt = Date . now ( ) ;
615+ while ( Date . now ( ) - startedAt < ( params . timeoutMs ?? 60_000 ) ) {
616+ const spawnError = params . getSpawnError ?.( ) ;
617+ if ( spawnError ) {
618+ throw new QaSuiteInfraError (
619+ "gateway_startup_unhealthy" ,
620+ `gateway failed to spawn: ${ formatErrorMessage ( spawnError ) } \n${ params . logs ( ) } ` ,
621+ { cause : spawnError } ,
622+ ) ;
623+ }
624+ if ( params . child . exitCode !== null || params . child . signalCode !== null ) {
625+ throw new QaSuiteInfraError (
626+ "gateway_startup_unhealthy" ,
627+ `gateway exited before listening (exitCode=${ String ( params . child . exitCode ) } , signal=${ String ( params . child . signalCode ) } ):\n${ params . logs ( ) } ` ,
628+ ) ;
629+ }
630+ try {
631+ if ( await fetchLocalGatewayListening ( params . baseUrl ) ) {
632+ return ;
633+ }
634+ } catch {
635+ // retry until the HTTP listener accepts requests
636+ }
637+ await sleep ( 100 ) ;
638+ }
639+ throw new QaSuiteInfraError (
640+ "gateway_startup_unhealthy" ,
641+ `gateway failed to listen before timeout:\n${ params . logs ( ) } ` ,
642+ ) ;
643+ }
644+
578645function isRetryableRpcStartupError ( error : unknown ) {
579646 const details = formatErrorMessage ( error ) ;
580647 return (
@@ -614,6 +681,7 @@ export async function startQaGatewayChild(params: {
614681 enabledPluginIds ?: string [ ] ;
615682 forwardHostHome ?: boolean ;
616683 mockAuthAgentIds ?: readonly string [ ] ;
684+ onListening ?: ( context : QaGatewayChildListeningContext ) => Promise < void > | void ;
617685 mutateConfig ?: ( cfg : OpenClawConfig ) => OpenClawConfig ;
618686 runtimeEnvPatch ?: NodeJS . ProcessEnv ;
619687} ) {
@@ -834,6 +902,21 @@ export async function startQaGatewayChild(params: {
834902 const getAttemptSpawnError = monitorQaGatewayChildSpawnError ( attemptChild , output ) ;
835903
836904 try {
905+ await waitForGatewayListening ( {
906+ baseUrl,
907+ logs,
908+ child : attemptChild ,
909+ getSpawnError : getAttemptSpawnError ,
910+ timeoutMs : 120_000 ,
911+ } ) ;
912+ await params . onListening ?.( {
913+ attempt,
914+ baseUrl,
915+ wsUrl,
916+ token : gatewayToken ,
917+ configPath,
918+ runtimeEnv : env ,
919+ } ) ;
837920 await waitForGatewayReady ( {
838921 baseUrl,
839922 logs,
0 commit comments