@@ -1020,35 +1020,48 @@ async function updateExistingScheduledTask(params: {
10201020async function shouldFallbackScheduledTaskLaunch ( params : {
10211021 env : GatewayServiceEnv ;
10221022 scriptPath : string ;
1023+ taskPort : number | null ;
1024+ preExistingListenerPids : ReadonlySet < number > ;
10231025} ) : Promise < boolean > {
1026+ const taskName = resolveTaskName ( params . env ) ;
1027+
1028+ // Reads raw schtasks state without the listener-backed enrichment so a
1029+ // foreground gateway on the same port cannot mask a task startup failure.
10241030 const readLaunchObservation = async ( ) : Promise < {
10251031 state : "running" | "not-yet-run" | "other" ;
10261032 signature : string ;
10271033 } > => {
1028- const runtime = await readScheduledTaskRuntime ( params . env ) . catch ( ( ) => null ) ;
1029- if ( runtime ?. status === "running" ) {
1030- return {
1031- state : "running" ,
1032- signature : [ runtime . state , runtime . lastRunTime , runtime . lastRunResult , runtime . detail ]
1033- . filter ( Boolean )
1034- . join ( "|" ) ,
1035- } ;
1034+ let parsedInfo : ScheduledTaskInfo | null = null ;
1035+ try {
1036+ const available = await execSchtasks ( [ "/Query" ] ) ;
1037+ if ( available . code === 0 ) {
1038+ const res = await execSchtasks ( [ "/Query" , "/TN" , taskName , "/V" , "/FO" , "LIST" ] ) ;
1039+ if ( res . code === 0 ) {
1040+ parsedInfo = parseSchtasksQuery ( res . stdout || "" ) ;
1041+ }
1042+ }
1043+ } catch {
1044+ // ignore
1045+ }
1046+ const derived = parsedInfo ? deriveScheduledTaskRuntimeStatus ( parsedInfo ) : null ;
1047+ const sig = [ parsedInfo ?. status , parsedInfo ?. lastRunTime , parsedInfo ?. lastRunResult ]
1048+ . filter ( Boolean )
1049+ . join ( "|" ) ;
1050+ if ( derived ?. status === "running" ) {
1051+ return { state : "running" , signature : sig } ;
1052+ }
1053+ if ( params . taskPort ) {
1054+ const pids = await resolveScheduledTaskGatewayListenerPids ( params . taskPort ) ;
1055+ const newPids = pids . filter ( ( pid ) => ! params . preExistingListenerPids . has ( pid ) ) ;
1056+ if ( newPids . length > 0 ) {
1057+ return { state : "running" , signature : sig } ;
1058+ }
10361059 }
1037- const normalizedResult = normalizeTaskResultCode ( runtime ?. lastRunResult ) ;
1060+ const normalizedResult = normalizeTaskResultCode ( parsedInfo ?. lastRunResult ) ;
10381061 if ( normalizedResult && NOT_YET_RUN_RESULT_CODES . has ( normalizedResult ) ) {
1039- return {
1040- state : "not-yet-run" ,
1041- signature : [ runtime ?. state , runtime ?. lastRunTime , runtime ?. lastRunResult , runtime ?. detail ]
1042- . filter ( Boolean )
1043- . join ( "|" ) ,
1044- } ;
1062+ return { state : "not-yet-run" , signature : sig } ;
10451063 }
1046- return {
1047- state : "other" ,
1048- signature : [ runtime ?. state , runtime ?. lastRunTime , runtime ?. lastRunResult , runtime ?. detail ]
1049- . filter ( Boolean )
1050- . join ( "|" ) ,
1051- } ;
1064+ return { state : "other" , signature : sig } ;
10521065 } ;
10531066
10541067 const hasLaunchEvidence = async ( ) : Promise < boolean > => {
@@ -1061,7 +1074,10 @@ async function shouldFallbackScheduledTaskLaunch(params: {
10611074 const manageGatewayPort = shouldManageGatewayListenerPort ( params . env ) ;
10621075 if ( manageGatewayPort && taskPort ) {
10631076 const listenerPids = await resolveScheduledTaskGatewayListenerPids ( taskPort ) ;
1064- if ( listenerPids . length > 0 ) {
1077+ const newListenerPids = listenerPids . filter (
1078+ ( pid ) => ! params . preExistingListenerPids . has ( pid ) ,
1079+ ) ;
1080+ if ( newListenerPids . length > 0 ) {
10651081 return true ;
10661082 }
10671083 }
@@ -1101,6 +1117,10 @@ async function shouldFallbackScheduledTaskLaunch(params: {
11011117 if ( ! commandLine ) {
11021118 return false ;
11031119 }
1120+ const pid = getSnapshotProcessId ( entry ) ;
1121+ if ( pid !== null && params . preExistingListenerPids . has ( pid ) ) {
1122+ return false ;
1123+ }
11041124 const argv = parseCmdScriptCommandLine ( entry . CommandLine ?? "" ) ;
11051125 return (
11061126 isGatewayArgv ( argv , { allowGatewayBinary : true } ) &&
@@ -1133,15 +1153,34 @@ async function runScheduledTaskOrThrow(params: {
11331153 env : GatewayServiceEnv ;
11341154 scriptPath : string ;
11351155} ) : Promise < void > {
1156+ // Snapshot only verified gateway PIDs before launching so a foreground gateway
1157+ // cannot mask task-start evidence and only owned processes are terminated (#91144).
1158+ const manageGatewayPort = shouldManageGatewayListenerPort ( params . env ) ;
1159+ const taskPort = manageGatewayPort ? await resolveScheduledTaskPort ( params . env ) : null ;
1160+ const preExistingListenerPids = taskPort
1161+ ? new Set ( findVerifiedGatewayListenerPidsOnPortSync ( taskPort ) )
1162+ : new Set < number > ( ) ;
1163+
11361164 const run = await execSchtasks ( [ "/Run" , "/TN" , params . taskName ] ) ;
11371165 if ( run . code !== 0 ) {
11381166 throw new Error ( `schtasks run failed: ${ run . stderr || run . stdout } ` . trim ( ) ) ;
11391167 }
11401168 if (
1141- ! ( await shouldFallbackScheduledTaskLaunch ( { env : params . env , scriptPath : params . scriptPath } ) )
1169+ ! ( await shouldFallbackScheduledTaskLaunch ( {
1170+ env : params . env ,
1171+ scriptPath : params . scriptPath ,
1172+ taskPort,
1173+ preExistingListenerPids,
1174+ } ) )
11421175 ) {
11431176 return ;
11441177 }
1178+ // A foreground gateway on the port causes the supervised fallback to yield to
1179+ // it as "healthy" and exit, leaving no managed gateway after the foreground closes.
1180+ // Use the pre-existing snapshot to avoid a TOCTOU kill of a newly-started managed gateway.
1181+ for ( const pid of preExistingListenerPids ) {
1182+ await terminateGatewayProcessTree ( pid , 300 ) ;
1183+ }
11451184 await launchFallbackTaskScript ( params . env ) ;
11461185}
11471186
0 commit comments