diff --git a/CHANGELOG.md b/CHANGELOG.md index 7719f17ca7d4..0f3287e7d270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/restart: eager-load the lifecycle runtime before in-place upgrade signal handling so package replacement does not deadlock restart imports. (#84890) Thanks @myps6415. - CLI/update: start managed Gateway update handoff helpers from a stable existing directory and tolerate deleted cwd/package roots during macOS LaunchAgent handoff. Fixes #83808. (#83875) Thanks @jason-allen-oneal. - Cron: honor `cron.retry.retryOn: ["network"]` for common network error codes such as `EAI_AGAIN`, `EHOSTUNREACH`, and `ENETUNREACH`. - Agents/OpenAI: preserve structured provider error code, type, and redacted body metadata on boundary-aware transport failures. diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index f2cb7fccfd1f..e8d234384ff0 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -107,6 +107,20 @@ export async function runGatewayLoop(params: { waitForHealthyChild?: (port: number, pid?: number, host?: string) => Promise; }) { let startupStartedAt = Date.now(); + // Eagerly resolve the lifecycle runtime module before installing signal + // listeners. Without this, every subsequent lifecycle path (SIGUSR1, + // SIGTERM-with-intent, restart iteration hook, stability bundle writer) + // depends on a dynamic import() call. After an in-place package upgrade + // (e.g. `npm install -g openclaw@latest` triggered via update.run), + // dist/ chunk hashes rotate while the process is still running. The next + // SIGUSR1 — including the one update.run schedules for itself — would + // hit ERR_MODULE_NOT_FOUND from inside its async IIFE, reject silently, + // and leave restart.ts's emittedRestartToken permanently unconsumed. + // From that point every scheduleGatewaySigusr1Restart() returns + // { coalesced: true } and the gateway never restarts. Priming the loader + // here pulls the whole re-export graph (lifecycle.runtime.ts is a 36-line + // re-export hub) into memory, immune to later disk rotation. + const eagerLifecycleRuntime = await loadGatewayLifecycleRuntimeModule(); let lock = await acquireGatewayLock({ port: params.lockPort }); let server: Awaited> | null = null; let shuttingDown = false; @@ -745,7 +759,20 @@ export async function runGatewayLoop(params: { const restartReason = peekGatewaySigusr1RestartReason(); markGatewaySigusr1RestartHandled(); request("restart", "SIGUSR1", restartReason); - })(); + })().catch((err) => { + // Defense in depth: if anything in the listener body rejects, the + // SIGUSR1 emit has already advanced emittedRestartToken but no one + // called markGatewaySigusr1RestartHandled. Without unsticking the + // token here, every subsequent scheduleGatewaySigusr1Restart() would + // silently coalesce into the dead in-flight signal and the gateway + // would never restart again until manually kickstarted. + gatewayLog.error(`SIGUSR1 handler failed: ${formatErrorMessage(err)}`); + try { + eagerLifecycleRuntime.markGatewaySigusr1RestartHandled(); + } catch { + // Best-effort: the eager reference itself is the recovery path. + } + }); }; process.on("SIGTERM", onSigterm);