fix(gateway): eager-load lifecycle runtime to survive in-place upgrades (#84890)

* 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 <noreply@anthropic.com>

* docs(changelog): mention lifecycle restart eager load

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Tung, Hsiao-Yu
2026-05-22 20:44:05 +08:00
committed by GitHub
parent 111bad1065
commit 4a9138556e
2 changed files with 29 additions and 1 deletions

View File

@@ -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.

View File

@@ -107,6 +107,20 @@ export async function runGatewayLoop(params: {
waitForHealthyChild?: (port: number, pid?: number, host?: string) => Promise<boolean>;
}) {
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<ReturnType<typeof startGatewayServer>> | 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);