diff --git a/CHANGELOG.md b/CHANGELOG.md index d4d6640ad58..4e6803269d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -210,6 +210,7 @@ Docs: https://docs.openclaw.ai - Heartbeat/scheduling: spread interval heartbeats across stable per-agent phases derived from gateway identity, so provider traffic is distributed more uniformly across the configured interval instead of clustering around startup-relative times. (#64560) Thanks @odysseus0. - Config/media: accept `tools.media.asyncCompletion.directSend` in strict config validation so gateways no longer reject the generated-schema-backed async media completion setting at startup. (#63618) Thanks @qiziAI. - Telegram/exec: preserve delayed exec completion routing for forum topics by pinning background exec completions to the topic where the run started even if the session route later drifts. (#64580) thanks @jalehman. +- Agents/locks: unregister the session write-lock `exit` cleanup handler during teardown so repeated lock lifecycle resets stop stacking process listeners in long-running gateway processes. (#65391) Thanks @adminfedres and @vincentkoc. ## 2026.4.9 diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts index e50ecd40805..c440e6576b0 100644 --- a/src/agents/session-write-lock.test.ts +++ b/src/agents/session-write-lock.test.ts @@ -426,6 +426,20 @@ describe("acquireSessionWriteLock", () => { await expect(fs.access(lockPath)).rejects.toThrow(); }); }); + + it("does not accumulate exit listeners across reset cycles", async () => { + const baselineExitListeners = process.listenerCount("exit"); + + await withTempSessionLockFile(async ({ sessionFile }) => { + for (let i = 0; i < 3; i += 1) { + const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); + await lock.release(); + resetSessionWriteLockStateForTest(); + expect(process.listenerCount("exit")).toBe(baselineExitListeners); + } + }); + }); + it("keeps other signal listeners registered", () => { const keepAlive = () => {}; const originalKill = process.kill.bind(process); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index f8da96afa4d..8f7205ff323 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -49,6 +49,7 @@ const MAX_LOCK_HOLD_MS = 2_147_000_000; type CleanupState = { registered: boolean; + exitHandler?: () => void; cleanupHandlers: Map void>; }; @@ -72,6 +73,7 @@ function resolveCleanupState(): CleanupState { if (!proc[CLEANUP_STATE_KEY]) { proc[CLEANUP_STATE_KEY] = { registered: false, + exitHandler: undefined, cleanupHandlers: new Map void>(), }; } @@ -254,12 +256,13 @@ function handleTerminationSignal(signal: CleanupSignal): void { function registerCleanupHandlers(): void { const cleanupState = resolveCleanupState(); - if (!cleanupState.registered) { - cleanupState.registered = true; + cleanupState.registered = true; + if (!cleanupState.exitHandler) { // Cleanup on normal exit and process.exit() calls - process.on("exit", () => { + cleanupState.exitHandler = () => { releaseAllLocksSync(); - }); + }; + process.on("exit", cleanupState.exitHandler); } ensureWatchdogStarted(DEFAULT_WATCHDOG_INTERVAL_MS); @@ -281,6 +284,10 @@ function registerCleanupHandlers(): void { function unregisterCleanupHandlers(): void { const cleanupState = resolveCleanupState(); + if (cleanupState.exitHandler) { + process.off("exit", cleanupState.exitHandler); + cleanupState.exitHandler = undefined; + } for (const [signal, handler] of cleanupState.cleanupHandlers) { process.off(signal, handler); }