From 4a9138556ea6f030f3fa79482290f84925e3ecde Mon Sep 17 00:00:00 2001 From: "Tung, Hsiao-Yu" Date: Fri, 22 May 2026 20:44:05 +0800 Subject: [PATCH] fix(gateway): eager-load lifecycle runtime to survive in-place upgrades (#84890) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(gateway): eager-load lifecycle runtime to survive in-place upgrades After a package-swap update (e.g. via update.run), dist/ chunk hashes rotate while the gateway is still running. The SIGUSR1 listener's first dynamic import of the lifecycle runtime module then throws ERR_MODULE_NOT_FOUND inside its async IIFE, silently rejects, and leaves restart.ts's emittedRestartToken permanently unconsumed. From that point every scheduleGatewaySigusr1Restart() — including the one update.run schedules for itself — returns { coalesced: true } without scheduling anything, and the gateway never restarts until manually kickstarted. Fix: 1. Eagerly resolve the lifecycle runtime module as the first statement of runGatewayLoop, before any signal listener is installed. lifecycle.runtime is a 36-line re-export hub, so loading it once pulls the entire restart / respawn / queue / sentinel / handoff graph into memory, immune to later disk rotation. If the module is missing at startup, fail fast with a loud error so the supervisor can recover instead of running half-broken. 2. Defense in depth: catch SIGUSR1 IIFE rejections and call markGatewaySigusr1RestartHandled() via the eagerly captured reference, so a transient listener failure doesn't permanently stick the restart token. Co-Authored-By: Claude Opus 4.7 * docs(changelog): mention lifecycle restart eager load --------- Co-authored-by: Claude Opus 4.7 Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + src/cli/gateway-cli/run-loop.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) 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);