diff --git a/CHANGELOG.md b/CHANGELOG.md index 96b4a39..d71bb72 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 7e70e76..4419290 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. 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. @@ -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-acquire.ts b/src/sidecar-lock-acquire.ts index 4dcba00..eff7a15 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; }; @@ -110,6 +111,11 @@ 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 0c19911..cb0d51a 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; @@ -118,11 +123,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.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 { @@ -137,18 +143,29 @@ 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); + releaseAllLocksSync(state, { preserveRetained: true }); } }; globalWithCleanup[GLOBAL_CLEANUP_HANDLER_KEY] = cleanup; 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 }; @@ -165,7 +182,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, ); @@ -247,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 e1e27e1..9751d7d 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"; @@ -62,6 +63,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, ` @@ -111,6 +125,71 @@ 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("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"); + 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, `