From f2ad890bc071c8fb7ded7fc20142de45825cf0fa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 4 Sep 2026 13:45:01 -0700 Subject: [PATCH 1/4] feat(file-lock): add retainOnExit opt-out for process-exit cleanup Locks acquired with retainOnExit: true keep their sidecar on natural process exit, for callers whose ownership records are deliberately fail-closed (PID death alone must not release them). Default exit-cleanup behavior is unchanged. --- CHANGELOG.md | 4 ++++ docs/sidecar-lock.md | 2 +- src/sidecar-lock-acquire.ts | 2 ++ src/sidecar-lock-types.ts | 7 +++++++ src/sidecar-lock.ts | 4 ++-- test/sidecar-lock-process-exit.test.ts | 13 +++++++++++++ 6 files changed, 29 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b4a39e..d71bb721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add `retainOnExit` to sidecar lock acquisition so deliberately retained ownership records (for example fail-closed build locks) survive natural process exit; default process-exit release behavior is unchanged. + ## 0.8.0 - 2026-09-04 ### Compatibility and upgrade notes diff --git a/docs/sidecar-lock.md b/docs/sidecar-lock.md index 7e70e76b..ea56fec1 100644 --- a/docs/sidecar-lock.md +++ b/docs/sidecar-lock.md @@ -21,7 +21,7 @@ try { The lock file sits next to the protected resource. If a process crashes mid-lock, the next acquirer notices the held entry, inspects its payload (PID, host, acquired-at timestamp), and decides — via `shouldReclaim` (defaulting to "is the lock older than `staleMs`?") — whether it should keep waiting or fail. -On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit")` handler attempts asynchronous cleanup of held Root-backed locks through their retained Root capability and ownership receipt. The synchronous `process.on("exit")` handler provides last-chance cleanup for raw locks and reclaim guards. Changed sidecars and failed Root cleanup remain in place; cleanup does not keep retrying during shutdown unless another acquisition re-arms it. +On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit")` handler attempts asynchronous cleanup of held Root-backed locks through their retained Root capability and ownership receipt. The synchronous `process.on("exit")` handler provides last-chance cleanup for raw locks and reclaim guards. Changed sidecars and failed Root cleanup remain in place; cleanup does not keep retrying during shutdown unless another acquisition re-arms it. Locks acquired with `retainOnExit: true` are exempt from both handlers: their sidecar stays in place after exit and is governed only by the caller's own stale policy. Always release locks in a `finally` block. Application-managed graceful shutdown can await `release()` or `manager.drain()` before terminating. Explicit `process.exit()`, uncaught failures, crashes, default signal handling, and fatal termination (including `SIGKILL`) do not reliably run asynchronous Root cleanup and may leave sidecars. Recover only after an application-owned liveness policy proves the holder cannot still be writing. diff --git a/src/sidecar-lock-acquire.ts b/src/sidecar-lock-acquire.ts index 4dcba000..1fcde6d1 100644 --- a/src/sidecar-lock-acquire.ts +++ b/src/sidecar-lock-acquire.ts @@ -43,6 +43,7 @@ export type HeldSidecarLock = { metadata: Record; releasePromise?: Promise; lockRoot?: Root; + retainOnExit?: boolean; parsePayload?: (raw: string) => unknown; compromiseTimer?: NodeJS.Timeout; }; @@ -226,6 +227,7 @@ export async function acquireSidecarLock> metadata?: Record; parsePayload?: (raw: string) => unknown; lockRoot?: Root; + /** + * Keep the lock file when the process exits naturally. Default `false`: + * process-exit handlers release held locks. Set only for deliberately + * retained ownership records (for example, fail-closed build locks) whose + * liveness is governed by the caller's own stale policy. + */ + retainOnExit?: boolean; onCompromised?: (info: SidecarLockCompromisedInfo) => void; compromiseCheckIntervalMs?: number; }; diff --git a/src/sidecar-lock.ts b/src/sidecar-lock.ts index 0c19911f..485ac14f 100644 --- a/src/sidecar-lock.ts +++ b/src/sidecar-lock.ts @@ -122,7 +122,7 @@ function releaseAllLocksSync(state: SidecarLockManagerState): void { for (const [normalizedTargetPath, held] of state.held) { void held.handle.close().catch(() => undefined); try { - if (!held.lockRoot && snapshotMatchesSync(held.lockPath, held.snapshot)) { + if (!held.retainOnExit && !held.lockRoot && snapshotMatchesSync(held.lockPath, held.snapshot)) { fsSync.rmSync(held.lockPath, { force: true }); } } catch { @@ -165,7 +165,7 @@ function ensureGlobalBeforeExitCleanupRegistered(): { armed: boolean } { lifecycle.armed = false; for (const state of getGlobalManagers().values()) { for (const [normalizedTargetPath, held] of Array.from(state.held.entries())) { - if (held.lockRoot) { + if (held.lockRoot && !held.retainOnExit) { void releaseHeldLock(state, normalizedTargetPath, held, { force: true }).catch( () => undefined, ); diff --git a/test/sidecar-lock-process-exit.test.ts b/test/sidecar-lock-process-exit.test.ts index e1e27e1a..cf385696 100644 --- a/test/sidecar-lock-process-exit.test.ts +++ b/test/sidecar-lock-process-exit.test.ts @@ -62,6 +62,19 @@ describe("sidecar lock natural process exit", () => { .resolves.toBe(JSON.parse(replacement)); }); + it.each(["Root", "raw"])("keeps an unchanged %s sidecar acquired with retainOnExit", async (kind) => { + const directory = await tempRoot("fs-safe-lock-exit-retain-"); + await runChild(directory, ` + const lock = await acquireFileLock(targetPath, { + ...options, retainOnExit: true, + lockRoot: ${kind === "raw" ? "undefined" : "capability"}, + }); + assert.equal(await lock.verifyStillHeld(), true); + `); + const raw = await fs.readFile(path.join(directory, "state.json.lock"), "utf8"); + expect(JSON.parse(raw)).toEqual({ owner: "caller" }); + }); + it("attempts failed Root cleanup once without an unhandled rejection or shutdown loop", async () => { const directory = await tempRoot("fs-safe-lock-exit-failure-"); const output = await runChild(directory, ` From 54f875cbaf4bf4b8eaed467ea121b499727af085 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 4 Sep 2026 14:09:19 -0700 Subject: [PATCH 2/4] fix(file-lock): keep reset independent of retainOnExit and document the option Review feedback: manager reset() is explicit teardown, not process exit, so it must still remove retained raw locks; only the exit handlers honor the flag. List retainOnExit in the published acquire-options docs and cover the reset path. --- docs/sidecar-lock.md | 1 + src/sidecar-lock.ts | 7 ++++--- test/sidecar-lock-process-exit.test.ts | 11 +++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/sidecar-lock.md b/docs/sidecar-lock.md index ea56fec1..6e3fa92c 100644 --- a/docs/sidecar-lock.md +++ b/docs/sidecar-lock.md @@ -82,6 +82,7 @@ type FileLockAcquireOptions> = { metadata?: Record; // attached to heldEntries() output for diagnostics parsePayload?: (raw: string) => unknown; lockRoot?: Root; + retainOnExit?: boolean; // keep the sidecar across process exit (default false) onCompromised?: (info: { lockPath: string; normalizedTargetPath: string }) => void; compromiseCheckIntervalMs?: number; }; diff --git a/src/sidecar-lock.ts b/src/sidecar-lock.ts index 485ac14f..44420cd2 100644 --- a/src/sidecar-lock.ts +++ b/src/sidecar-lock.ts @@ -118,11 +118,12 @@ function releaseAllReclaimGuardsSync(state: SidecarLockManagerState): void { } } -function releaseAllLocksSync(state: SidecarLockManagerState): void { +function releaseAllLocksSync(state: SidecarLockManagerState, options?: { preserveRetained?: boolean }): void { for (const [normalizedTargetPath, held] of state.held) { void held.handle.close().catch(() => undefined); try { - if (!held.retainOnExit && !held.lockRoot && snapshotMatchesSync(held.lockPath, held.snapshot)) { + const retained = options?.preserveRetained === true && held.retainOnExit; + if (!retained && !held.lockRoot && snapshotMatchesSync(held.lockPath, held.snapshot)) { fsSync.rmSync(held.lockPath, { force: true }); } } catch { @@ -142,7 +143,7 @@ function ensureGlobalExitCleanupRegistered(): void { globalWithCleanup[GLOBAL_CLEANUP_KEY] = true; const cleanup = () => { for (const state of getGlobalManagers().values()) { - releaseAllLocksSync(state); + releaseAllLocksSync(state, { preserveRetained: true }); } }; globalWithCleanup[GLOBAL_CLEANUP_HANDLER_KEY] = cleanup; diff --git a/test/sidecar-lock-process-exit.test.ts b/test/sidecar-lock-process-exit.test.ts index cf385696..1b789d97 100644 --- a/test/sidecar-lock-process-exit.test.ts +++ b/test/sidecar-lock-process-exit.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; +import { createFileLockManager } from "../src/file-lock.js"; import { useTempDirs } from "./helpers/vitest.js"; import { useSuiteFixture } from "./helpers/suite-fixture.js"; @@ -124,6 +125,16 @@ describe("sidecar lock natural process exit", () => { await expectAbsent(directory); }); + it("manager reset still releases a retainOnExit raw lock", async () => { + const directory = await tempRoot("fs-safe-lock-reset-retain-"); + const target = path.join(directory, "state.json"); + const manager = createFileLockManager("reset-retain"); + const lock = await manager.acquire(target, { payload: () => ({ owner: "caller" }), retainOnExit: true }); + expect(await lock.verifyStillHeld()).toBe(true); + manager.reset(); + await expectAbsent(directory); + }); + it("joins an explicit release already in flight", async () => { const directory = await tempRoot("fs-safe-lock-exit-in-flight-"); const output = await runChild(directory, ` From 70ea15a5bc2b308de6f09cfafe0c8a3d4fcaa9e2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 4 Sep 2026 14:30:30 -0700 Subject: [PATCH 3/4] fix(file-lock): fail closed when a legacy copy owns exit cleanup retainOnExit is only honored when this package copy registered the process-exit handlers. When an older copy registered them first, retained acquisition now rejects with helper-unavailable instead of silently losing the guarantee at shutdown. --- docs/sidecar-lock.md | 2 +- src/sidecar-lock.ts | 22 +++++++++++++++++++++ test/sidecar-lock-process-exit.test.ts | 27 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/docs/sidecar-lock.md b/docs/sidecar-lock.md index 6e3fa92c..44192904 100644 --- a/docs/sidecar-lock.md +++ b/docs/sidecar-lock.md @@ -21,7 +21,7 @@ try { The lock file sits next to the protected resource. If a process crashes mid-lock, the next acquirer notices the held entry, inspects its payload (PID, host, acquired-at timestamp), and decides — via `shouldReclaim` (defaulting to "is the lock older than `staleMs`?") — whether it should keep waiting or fail. -On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit")` handler attempts asynchronous cleanup of held Root-backed locks through their retained Root capability and ownership receipt. The synchronous `process.on("exit")` handler provides last-chance cleanup for raw locks and reclaim guards. Changed sidecars and failed Root cleanup remain in place; cleanup does not keep retrying during shutdown unless another acquisition re-arms it. Locks acquired with `retainOnExit: true` are exempt from both handlers: their sidecar stays in place after exit and is governed only by the caller's own stale policy. +On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit")` handler attempts asynchronous cleanup of held Root-backed locks through their retained Root capability and ownership receipt. The synchronous `process.on("exit")` handler provides last-chance cleanup for raw locks and reclaim guards. Changed sidecars and failed Root cleanup remain in place; cleanup does not keep retrying during shutdown unless another acquisition re-arms it. Locks acquired with `retainOnExit: true` are exempt from both handlers: their sidecar stays in place after exit and is governed only by the caller's own stale policy. Because exit handlers are globally deduplicated across package copies, `retainOnExit` fails closed with `helper-unavailable` if an older copy that cannot honor it registered the handlers first. Always release locks in a `finally` block. Application-managed graceful shutdown can await `release()` or `manager.drain()` before terminating. Explicit `process.exit()`, uncaught failures, crashes, default signal handling, and fatal termination (including `SIGKILL`) do not reliably run asynchronous Root cleanup and may leave sidecars. Recover only after an application-owned liveness policy proves the holder cannot still be writing. diff --git a/src/sidecar-lock.ts b/src/sidecar-lock.ts index 44420cd2..cb0d51ac 100644 --- a/src/sidecar-lock.ts +++ b/src/sidecar-lock.ts @@ -1,4 +1,5 @@ import fsSync from "node:fs"; +import { FsSafeError } from "./errors.js"; import { sameFileIdentity } from "./file-identity.js"; import { removeSidecarLockIfUnchanged, @@ -34,6 +35,10 @@ const GLOBAL_STATE_KEY = Symbol.for("fsSafe.sidecarLockManagers"); const GLOBAL_CLEANUP_KEY = Symbol.for("fsSafe.sidecarLockCleanupRegistered"); const GLOBAL_CLEANUP_HANDLER_KEY = Symbol.for("fsSafe.sidecarLockCleanupHandler"); const GLOBAL_BEFORE_EXIT_KEY = Symbol.for("fsSafe.sidecarLockBeforeExitCleanup"); +// Set by copies whose exit handlers honor retainOnExit. When an older package +// copy registered the handlers first, this marker stays absent and retained +// acquisitions must fail closed rather than silently lose the guarantee. +const GLOBAL_RETAIN_AWARE_KEY = Symbol.for("fsSafe.sidecarLockRetainAwareCleanup"); function getGlobalManagers(): Map { const globalWithState = globalThis as typeof globalThis & { [GLOBAL_STATE_KEY]?: Map; @@ -138,9 +143,11 @@ function ensureGlobalExitCleanupRegistered(): void { const globalWithCleanup = globalThis as typeof globalThis & { [GLOBAL_CLEANUP_KEY]?: boolean; [GLOBAL_CLEANUP_HANDLER_KEY]?: () => void; + [GLOBAL_RETAIN_AWARE_KEY]?: boolean; }; if (globalWithCleanup[GLOBAL_CLEANUP_KEY]) return; globalWithCleanup[GLOBAL_CLEANUP_KEY] = true; + globalWithCleanup[GLOBAL_RETAIN_AWARE_KEY] = true; const cleanup = () => { for (const state of getGlobalManagers().values()) { releaseAllLocksSync(state, { preserveRetained: true }); @@ -150,6 +157,15 @@ function ensureGlobalExitCleanupRegistered(): void { process.on("exit", cleanup); } +/** True when a retain-unaware package copy registered the process-exit handlers first. */ +export function exitCleanupCannotRetain(): boolean { + const globalWithCleanup = globalThis as typeof globalThis & { + [GLOBAL_CLEANUP_KEY]?: boolean; + [GLOBAL_RETAIN_AWARE_KEY]?: boolean; + }; + return globalWithCleanup[GLOBAL_CLEANUP_KEY] === true && globalWithCleanup[GLOBAL_RETAIN_AWARE_KEY] !== true; +} + function ensureGlobalBeforeExitCleanupRegistered(): { armed: boolean } { const globalWithCleanup = globalThis as typeof globalThis & { [GLOBAL_BEFORE_EXIT_KEY]?: { armed: boolean }; @@ -248,6 +264,12 @@ export function createSidecarLockManager(key: string) { async function acquire>( options: SidecarLockAcquireOptions, ): Promise { + if (options.retainOnExit === true && exitCleanupCannotRetain()) { + throw new FsSafeError( + "helper-unavailable", + "retainOnExit requires this process's exit handlers to be retain-aware; an older package copy registered them first", + ); + } return await acquireSidecarLock(options, { held: state.held, reclaimGuards: state.reclaimGuards, diff --git a/test/sidecar-lock-process-exit.test.ts b/test/sidecar-lock-process-exit.test.ts index 1b789d97..7023bb12 100644 --- a/test/sidecar-lock-process-exit.test.ts +++ b/test/sidecar-lock-process-exit.test.ts @@ -135,6 +135,33 @@ describe("sidecar lock natural process exit", () => { await expectAbsent(directory); }); + it("rejects retainOnExit when a legacy package copy owns the exit handlers", async () => { + const globalWithCleanup = globalThis as Record; + const cleanupKey = Symbol.for("fsSafe.sidecarLockCleanupRegistered"); + const retainAwareKey = Symbol.for("fsSafe.sidecarLockRetainAwareCleanup"); + const priorCleanup = globalWithCleanup[cleanupKey]; + const priorRetainAware = globalWithCleanup[retainAwareKey]; + // Simulate a legacy copy that registered the exit handler without the + // retain-aware marker; the new copy must fail closed, not silently lose it. + globalWithCleanup[cleanupKey] = true; + delete globalWithCleanup[retainAwareKey]; + try { + const directory = await tempRoot("fs-safe-lock-legacy-retain-"); + const manager = createFileLockManager("legacy-retain"); + await expect( + manager.acquire(path.join(directory, "state.json"), { + payload: () => ({ owner: "caller" }), + retainOnExit: true, + }), + ).rejects.toMatchObject({ code: "helper-unavailable" }); + } finally { + if (priorCleanup === undefined) delete globalWithCleanup[cleanupKey]; + else globalWithCleanup[cleanupKey] = priorCleanup; + if (priorRetainAware === undefined) delete globalWithCleanup[retainAwareKey]; + else globalWithCleanup[retainAwareKey] = priorRetainAware; + } + }); + it("joins an explicit release already in flight", async () => { const directory = await tempRoot("fs-safe-lock-exit-in-flight-"); const output = await runChild(directory, ` From 0b761f6a9e85cb7a64d41351bed8fea58461d0d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 4 Sep 2026 14:45:42 -0700 Subject: [PATCH 4/4] fix(file-lock): upgrade held locks to retainOnExit monotonically A reentrant same-owner acquisition reuses the held entry; a retainOnExit request now upgrades it so the sidecar survives exit. A later default acquisition never downgrades. Covers the default-then-retain sequence in-process and across natural child exit. --- src/sidecar-lock-acquire.ts | 5 +++++ test/sidecar-lock-process-exit.test.ts | 28 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/sidecar-lock-acquire.ts b/src/sidecar-lock-acquire.ts index 1fcde6d1..eff7a15f 100644 --- a/src/sidecar-lock-acquire.ts +++ b/src/sidecar-lock-acquire.ts @@ -111,6 +111,11 @@ export async function acquireSidecarLock { await expectAbsent(directory); }); + it("upgrades an in-process reentrant hold to retainOnExit", async () => { + const directory = await tempRoot("fs-safe-lock-reentrant-retain-"); + const target = path.join(directory, "state.json"); + const manager = createFileLockManager("reentrant-retain"); + const options = { + payload: () => ({ owner: "caller" }), + reentrantOwner: "owner", + }; + await manager.acquire(target, options); + await manager.acquire(target, { ...options, retainOnExit: true }); + const held = [...globalThis[Symbol.for("fsSafe.sidecarLockManagers")].get("reentrant-retain").held.values()][0]; + expect(held.refCount).toBe(2); + expect(held.retainOnExit).toBe(true); + await manager.reset(); + }); + + it("keeps the sidecar when a reentrant retainOnExit upgrades a default hold before exit", async () => { + const directory = await tempRoot("fs-safe-lock-exit-upgrade-"); + await runChild(directory, ` + const manager = createFileLockManager("exit-upgrade"); + const nested = { ...options, reentrantOwner: "owner" }; + await manager.acquire(targetPath, nested); + await manager.acquire(targetPath, { ...nested, retainOnExit: true }); + `); + const raw = await fs.readFile(path.join(directory, "state.json.lock"), "utf8"); + expect(JSON.parse(raw)).toEqual({ owner: "caller" }); + }); + it("rejects retainOnExit when a legacy package copy owns the exit handlers", async () => { const globalWithCleanup = globalThis as Record; const cleanupKey = Symbol.for("fsSafe.sidecarLockCleanupRegistered");