2026-03-07 21:20:29 -05:00
import { spawnSync } from "node:child_process" ;
import fsSync from "node:fs" ;
import { isRestartEnabled } from "../../config/commands.js" ;
2026-03-07 20:48:13 -05:00
import { readBestEffortConfig , resolveGatewayPort } from "../../config/config.js" ;
2026-01-14 01:08:15 +00:00
import { resolveGatewayService } from "../../daemon/service.js" ;
2026-03-07 21:20:29 -05:00
import { probeGateway } from "../../gateway/probe.js" ;
import { findGatewayPidsOnPortSync } from "../../infra/restart.js" ;
2026-02-21 18:02:05 +01:00
import { defaultRuntime } from "../../runtime.js" ;
import { theme } from "../../terminal/theme.js" ;
import { formatCliCommand } from "../command-format.js" ;
2026-02-14 14:00:34 +00:00
import {
runServiceRestart ,
runServiceStart ,
runServiceStop ,
runServiceUninstall ,
} from "./lifecycle-core.js" ;
2026-02-21 18:02:05 +01:00
import {
2026-02-23 01:49:54 +01:00
DEFAULT_RESTART_HEALTH_ATTEMPTS ,
DEFAULT_RESTART_HEALTH_DELAY_MS ,
2026-03-07 21:20:29 -05:00
renderGatewayPortHealthDiagnostics ,
2026-02-21 18:02:05 +01:00
renderRestartDiagnostics ,
terminateStaleGatewayPids ,
2026-03-07 21:20:29 -05:00
waitForGatewayHealthyListener ,
2026-02-21 18:02:05 +01:00
waitForGatewayHealthyRestart ,
} from "./restart-health.js" ;
import { parsePortFromArgs , renderGatewayServiceStartHints } from "./shared.js" ;
2026-02-18 01:34:35 +00:00
import type { DaemonLifecycleOptions } from "./types.js" ;
2026-01-14 01:08:15 +00:00
2026-02-23 01:49:54 +01:00
const POST_RESTART_HEALTH_ATTEMPTS = DEFAULT_RESTART_HEALTH_ATTEMPTS ;
const POST_RESTART_HEALTH_DELAY_MS = DEFAULT_RESTART_HEALTH_DELAY_MS ;
2026-02-21 18:02:05 +01:00
2026-03-07 21:20:29 -05:00
async function resolveGatewayLifecyclePort ( service = resolveGatewayService ( ) ) {
2026-02-21 18:02:05 +01:00
const command = await service . readCommand ( process . env ) . catch ( ( ) = > null ) ;
const serviceEnv = command ? . environment ? ? undefined ;
const mergedEnv = {
. . . ( process . env as Record < string , string | undefined > ) ,
. . . ( serviceEnv ? ? undefined ) ,
} as NodeJS . ProcessEnv ;
const portFromArgs = parsePortFromArgs ( command ? . programArguments ) ;
2026-03-07 20:48:13 -05:00
return portFromArgs ? ? resolveGatewayPort ( await readBestEffortConfig ( ) , mergedEnv ) ;
2026-02-21 18:02:05 +01:00
}
2026-03-07 21:20:29 -05:00
function normalizeProcArg ( arg : string ) : string {
return arg . replaceAll ( "\\" , "/" ) . toLowerCase ( ) ;
}
function parseProcCmdline ( raw : string ) : string [ ] {
return raw
. split ( "\0" )
. map ( ( entry ) = > entry . trim ( ) )
. filter ( Boolean ) ;
}
function isGatewayArgv ( args : string [ ] ) : boolean {
const normalized = args . map ( normalizeProcArg ) ;
if ( ! normalized . includes ( "gateway" ) ) {
return false ;
}
const entryCandidates = [
"dist/index.js" ,
"dist/entry.js" ,
"openclaw.mjs" ,
"scripts/run-node.mjs" ,
"src/index.ts" ,
] ;
if ( normalized . some ( ( arg ) = > entryCandidates . some ( ( entry ) = > arg . endsWith ( entry ) ) ) ) {
return true ;
}
const exe = normalized [ 0 ] ? ? "" ;
return exe . endsWith ( "/openclaw" ) || exe === "openclaw" || exe . endsWith ( "/openclaw-gateway" ) ;
}
function readGatewayProcessArgsSync ( pid : number ) : string [ ] | null {
if ( process . platform === "linux" ) {
try {
return parseProcCmdline ( fsSync . readFileSync ( ` /proc/ ${ pid } /cmdline ` , "utf8" ) ) ;
} catch {
return null ;
}
}
if ( process . platform === "darwin" ) {
const ps = spawnSync ( "ps" , [ "-o" , "command=" , "-p" , String ( pid ) ] , {
encoding : "utf8" ,
timeout : 1000 ,
} ) ;
if ( ps . error || ps . status !== 0 ) {
return null ;
}
const command = ps . stdout . trim ( ) ;
return command ? command . split ( /\s+/ ) : null ;
}
return null ;
}
function resolveGatewayListenerPids ( port : number ) : number [ ] {
return Array . from ( new Set ( findGatewayPidsOnPortSync ( port ) ) )
. filter ( ( pid ) : pid is number = > Number . isFinite ( pid ) && pid > 0 )
. filter ( ( pid ) = > {
const args = readGatewayProcessArgsSync ( pid ) ;
return args != null && isGatewayArgv ( args ) ;
} ) ;
}
function resolveGatewayPortFallback ( ) : Promise < number > {
return readBestEffortConfig ( )
. then ( ( cfg ) = > resolveGatewayPort ( cfg , process . env ) )
. catch ( ( ) = > resolveGatewayPort ( undefined , process . env ) ) ;
}
function signalGatewayPid ( pid : number , signal : "SIGTERM" | "SIGUSR1" ) {
const args = readGatewayProcessArgsSync ( pid ) ;
if ( ! args || ! isGatewayArgv ( args ) ) {
throw new Error ( ` refusing to signal non-gateway process pid ${ pid } ` ) ;
}
process . kill ( pid , signal ) ;
}
function formatGatewayPidList ( pids : number [ ] ) : string {
return pids . join ( ", " ) ;
}
async function assertUnmanagedGatewayRestartEnabled ( port : number ) : Promise < void > {
const probe = await probeGateway ( {
url : ` ws://127.0.0.1: ${ port } ` ,
auth : {
token : process.env.OPENCLAW_GATEWAY_TOKEN?.trim ( ) || undefined ,
password : process.env.OPENCLAW_GATEWAY_PASSWORD?.trim ( ) || undefined ,
} ,
timeoutMs : 1_000 ,
} ) . catch ( ( ) = > null ) ;
if ( ! probe ? . ok ) {
return ;
}
if ( ! isRestartEnabled ( probe . configSnapshot as { commands? : unknown } | undefined ) ) {
throw new Error (
"Gateway restart is disabled in the running gateway config (commands.restart=false); unmanaged SIGUSR1 restart would be ignored" ,
) ;
}
}
function resolveVerifiedGatewayListenerPids ( port : number ) : number [ ] {
return resolveGatewayListenerPids ( port ) . filter (
( pid ) : pid is number = > Number . isFinite ( pid ) && pid > 0 ,
) ;
}
async function stopGatewayWithoutServiceManager ( port : number ) {
const pids = resolveVerifiedGatewayListenerPids ( port ) ;
if ( pids . length === 0 ) {
return null ;
}
for ( const pid of pids ) {
signalGatewayPid ( pid , "SIGTERM" ) ;
}
return {
result : "stopped" as const ,
message : ` Gateway stop signal sent to unmanaged process ${ pids . length === 1 ? "" : "es" } on port ${ port } : ${ formatGatewayPidList ( pids ) } . ` ,
} ;
}
async function restartGatewayWithoutServiceManager ( port : number ) {
await assertUnmanagedGatewayRestartEnabled ( port ) ;
const pids = resolveVerifiedGatewayListenerPids ( port ) ;
if ( pids . length === 0 ) {
return null ;
}
if ( pids . length > 1 ) {
throw new Error (
` multiple gateway processes are listening on port ${ port } : ${ formatGatewayPidList ( pids ) } ; use "openclaw gateway status --deep" before retrying restart ` ,
) ;
}
signalGatewayPid ( pids [ 0 ] , "SIGUSR1" ) ;
return {
result : "restarted" as const ,
message : ` Gateway restart signal sent to unmanaged process on port ${ port } : ${ pids [ 0 ] } . ` ,
} ;
}
2026-01-16 05:40:35 +00:00
export async function runDaemonUninstall ( opts : DaemonLifecycleOptions = { } ) {
2026-02-14 14:00:34 +00:00
return await runServiceUninstall ( {
serviceNoun : "Gateway" ,
service : resolveGatewayService ( ) ,
opts ,
stopBeforeUninstall : true ,
assertNotLoadedAfterUninstall : true ,
2026-01-16 05:40:35 +00:00
} ) ;
2026-01-14 01:08:15 +00:00
}
2026-01-16 05:40:35 +00:00
export async function runDaemonStart ( opts : DaemonLifecycleOptions = { } ) {
2026-02-14 14:00:34 +00:00
return await runServiceStart ( {
serviceNoun : "Gateway" ,
service : resolveGatewayService ( ) ,
renderStartHints : renderGatewayServiceStartHints ,
opts ,
2026-01-16 05:40:35 +00:00
} ) ;
2026-01-14 01:08:15 +00:00
}
2026-01-16 05:40:35 +00:00
export async function runDaemonStop ( opts : DaemonLifecycleOptions = { } ) {
2026-03-07 21:20:29 -05:00
const service = resolveGatewayService ( ) ;
const gatewayPort = await resolveGatewayLifecyclePort ( service ) . catch ( ( ) = >
resolveGatewayPortFallback ( ) ,
) ;
2026-02-14 14:00:34 +00:00
return await runServiceStop ( {
serviceNoun : "Gateway" ,
2026-03-07 21:20:29 -05:00
service ,
2026-02-14 14:00:34 +00:00
opts ,
2026-03-07 21:20:29 -05:00
onNotLoaded : async ( ) = > stopGatewayWithoutServiceManager ( gatewayPort ) ,
2026-01-16 05:40:35 +00:00
} ) ;
2026-01-14 01:08:15 +00:00
}
/ * *
2026-01-21 17:45:06 +00:00
* Restart the gateway service service .
2026-01-14 01:08:15 +00:00
* @returns ` true ` if restart succeeded , ` false ` if the service was not loaded .
* Throws / exits on check or restart failures .
* /
2026-01-16 05:40:35 +00:00
export async function runDaemonRestart ( opts : DaemonLifecycleOptions = { } ) : Promise < boolean > {
2026-02-21 18:02:05 +01:00
const json = Boolean ( opts . json ) ;
const service = resolveGatewayService ( ) ;
2026-03-07 21:20:29 -05:00
let restartedWithoutServiceManager = false ;
const restartPort = await resolveGatewayLifecyclePort ( service ) . catch ( ( ) = >
resolveGatewayPortFallback ( ) ,
2026-02-21 18:02:05 +01:00
) ;
2026-02-23 01:49:54 +01:00
const restartWaitMs = POST_RESTART_HEALTH_ATTEMPTS * POST_RESTART_HEALTH_DELAY_MS ;
const restartWaitSeconds = Math . round ( restartWaitMs / 1000 ) ;
2026-02-21 18:02:05 +01:00
2026-02-14 14:00:34 +00:00
return await runServiceRestart ( {
serviceNoun : "Gateway" ,
2026-02-21 18:02:05 +01:00
service ,
2026-02-14 14:00:34 +00:00
renderStartHints : renderGatewayServiceStartHints ,
opts ,
2026-02-17 08:44:07 -05:00
checkTokenDrift : true ,
2026-03-07 21:20:29 -05:00
onNotLoaded : async ( ) = > {
const handled = await restartGatewayWithoutServiceManager ( restartPort ) ;
if ( handled ) {
restartedWithoutServiceManager = true ;
}
return handled ;
} ,
2026-02-21 18:02:05 +01:00
postRestartCheck : async ( { warnings , fail , stdout } ) = > {
2026-03-07 21:20:29 -05:00
if ( restartedWithoutServiceManager ) {
const health = await waitForGatewayHealthyListener ( {
port : restartPort ,
attempts : POST_RESTART_HEALTH_ATTEMPTS ,
delayMs : POST_RESTART_HEALTH_DELAY_MS ,
} ) ;
if ( health . healthy ) {
return ;
}
const diagnostics = renderGatewayPortHealthDiagnostics ( health ) ;
const timeoutLine = ` Timed out after ${ restartWaitSeconds } s waiting for gateway port ${ restartPort } to become healthy. ` ;
if ( ! json ) {
defaultRuntime . log ( theme . warn ( timeoutLine ) ) ;
for ( const line of diagnostics ) {
defaultRuntime . log ( theme . muted ( line ) ) ;
}
} else {
warnings . push ( timeoutLine ) ;
warnings . push ( . . . diagnostics ) ;
}
fail ( ` Gateway restart timed out after ${ restartWaitSeconds } s waiting for health checks. ` , [
formatCliCommand ( "openclaw gateway status --deep" ) ,
formatCliCommand ( "openclaw doctor" ) ,
] ) ;
}
2026-02-21 18:02:05 +01:00
let health = await waitForGatewayHealthyRestart ( {
service ,
port : restartPort ,
attempts : POST_RESTART_HEALTH_ATTEMPTS ,
delayMs : POST_RESTART_HEALTH_DELAY_MS ,
2026-03-02 14:24:25 +00:00
includeUnknownListenersAsStale : process.platform === "win32" ,
2026-02-21 18:02:05 +01:00
} ) ;
if ( ! health . healthy && health . staleGatewayPids . length > 0 ) {
const staleMsg = ` Found stale gateway process(es): ${ health . staleGatewayPids . join ( ", " ) } . ` ;
warnings . push ( staleMsg ) ;
if ( ! json ) {
defaultRuntime . log ( theme . warn ( staleMsg ) ) ;
defaultRuntime . log ( theme . muted ( "Stopping stale process(es) and retrying restart..." ) ) ;
}
await terminateStaleGatewayPids ( health . staleGatewayPids ) ;
await service . restart ( { env : process.env , stdout } ) ;
health = await waitForGatewayHealthyRestart ( {
service ,
port : restartPort ,
attempts : POST_RESTART_HEALTH_ATTEMPTS ,
delayMs : POST_RESTART_HEALTH_DELAY_MS ,
2026-03-02 14:24:25 +00:00
includeUnknownListenersAsStale : process.platform === "win32" ,
2026-02-21 18:02:05 +01:00
} ) ;
}
if ( health . healthy ) {
return ;
}
const diagnostics = renderRestartDiagnostics ( health ) ;
2026-02-23 01:49:54 +01:00
const timeoutLine = ` Timed out after ${ restartWaitSeconds } s waiting for gateway port ${ restartPort } to become healthy. ` ;
const runningNoPortLine =
health . runtime . status === "running" && health . portUsage . status === "free"
? ` Gateway process is running but port ${ restartPort } is still free (startup hang/crash loop or very slow VM startup). `
: null ;
2026-02-21 18:02:05 +01:00
if ( ! json ) {
2026-02-23 01:49:54 +01:00
defaultRuntime . log ( theme . warn ( timeoutLine ) ) ;
if ( runningNoPortLine ) {
defaultRuntime . log ( theme . warn ( runningNoPortLine ) ) ;
}
2026-02-21 18:02:05 +01:00
for ( const line of diagnostics ) {
defaultRuntime . log ( theme . muted ( line ) ) ;
}
} else {
2026-02-23 01:49:54 +01:00
warnings . push ( timeoutLine ) ;
if ( runningNoPortLine ) {
warnings . push ( runningNoPortLine ) ;
}
2026-02-21 18:02:05 +01:00
warnings . push ( . . . diagnostics ) ;
}
2026-02-23 01:49:54 +01:00
fail ( ` Gateway restart timed out after ${ restartWaitSeconds } s waiting for health checks. ` , [
2026-02-24 09:19:59 +00:00
formatCliCommand ( "openclaw gateway status --deep" ) ,
2026-02-21 18:02:05 +01:00
formatCliCommand ( "openclaw doctor" ) ,
] ) ;
} ,
2026-02-14 14:00:34 +00:00
} ) ;
2026-01-14 01:08:15 +00:00
}