Skip to content

Commit 4a91385

Browse files
myps6415claudevincentkoc
authored
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 <[email protected]> * docs(changelog): mention lifecycle restart eager load --------- Co-authored-by: Claude Opus 4.7 <[email protected]> Co-authored-by: Vincent Koc <[email protected]>
1 parent 111bad1 commit 4a91385

2 files changed

Lines changed: 29 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai
3535

3636
### Fixes
3737

38+
- Gateway/restart: eager-load the lifecycle runtime before in-place upgrade signal handling so package replacement does not deadlock restart imports. (#84890) Thanks @myps6415.
3839
- 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.
3940
- Cron: honor `cron.retry.retryOn: ["network"]` for common network error codes such as `EAI_AGAIN`, `EHOSTUNREACH`, and `ENETUNREACH`.
4041
- Agents/OpenAI: preserve structured provider error code, type, and redacted body metadata on boundary-aware transport failures.

src/cli/gateway-cli/run-loop.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,20 @@ export async function runGatewayLoop(params: {
107107
waitForHealthyChild?: (port: number, pid?: number, host?: string) => Promise<boolean>;
108108
}) {
109109
let startupStartedAt = Date.now();
110+
// Eagerly resolve the lifecycle runtime module before installing signal
111+
// listeners. Without this, every subsequent lifecycle path (SIGUSR1,
112+
// SIGTERM-with-intent, restart iteration hook, stability bundle writer)
113+
// depends on a dynamic import() call. After an in-place package upgrade
114+
// (e.g. `npm install -g openclaw@latest` triggered via update.run),
115+
// dist/ chunk hashes rotate while the process is still running. The next
116+
// SIGUSR1 — including the one update.run schedules for itself — would
117+
// hit ERR_MODULE_NOT_FOUND from inside its async IIFE, reject silently,
118+
// and leave restart.ts's emittedRestartToken permanently unconsumed.
119+
// From that point every scheduleGatewaySigusr1Restart() returns
120+
// { coalesced: true } and the gateway never restarts. Priming the loader
121+
// here pulls the whole re-export graph (lifecycle.runtime.ts is a 36-line
122+
// re-export hub) into memory, immune to later disk rotation.
123+
const eagerLifecycleRuntime = await loadGatewayLifecycleRuntimeModule();
110124
let lock = await acquireGatewayLock({ port: params.lockPort });
111125
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
112126
let shuttingDown = false;
@@ -745,7 +759,20 @@ export async function runGatewayLoop(params: {
745759
const restartReason = peekGatewaySigusr1RestartReason();
746760
markGatewaySigusr1RestartHandled();
747761
request("restart", "SIGUSR1", restartReason);
748-
})();
762+
})().catch((err) => {
763+
// Defense in depth: if anything in the listener body rejects, the
764+
// SIGUSR1 emit has already advanced emittedRestartToken but no one
765+
// called markGatewaySigusr1RestartHandled. Without unsticking the
766+
// token here, every subsequent scheduleGatewaySigusr1Restart() would
767+
// silently coalesce into the dead in-flight signal and the gateway
768+
// would never restart again until manually kickstarted.
769+
gatewayLog.error(`SIGUSR1 handler failed: ${formatErrorMessage(err)}`);
770+
try {
771+
eagerLifecycleRuntime.markGatewaySigusr1RestartHandled();
772+
} catch {
773+
// Best-effort: the eager reference itself is the recovery path.
774+
}
775+
});
749776
};
750777

751778
process.on("SIGTERM", onSigterm);

0 commit comments

Comments
 (0)