Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/sidecar-lock.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -82,6 +82,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
metadata?: Record<string, unknown>; // 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;
};
Expand Down
7 changes: 7 additions & 0 deletions src/sidecar-lock-acquire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export type HeldSidecarLock = {
metadata: Record<string, unknown>;
releasePromise?: Promise<void>;
lockRoot?: Root;
retainOnExit?: boolean;
parsePayload?: (raw: string) => unknown;
compromiseTimer?: NodeJS.Timeout;
};
Expand Down Expand Up @@ -110,6 +111,11 @@ export async function acquireSidecarLock<TPayload extends Record<string, unknown
options.reentrantOwner === held.reentrantOwner
) {
held.refCount += 1;
// Retention is monotonic: any same-owner request to keep the sidecar on
// exit upgrades the held lock; a later default acquisition never revokes it.
if (options.retainOnExit === true) {
held.retainOnExit = true;
}
return context.handleForHeldLock(normalizedTargetPath, held);
}
}
Expand Down Expand Up @@ -226,6 +232,7 @@ export async function acquireSidecarLock<TPayload extends Record<string, unknown
acquiredAt: Date.now(),
metadata: options.metadata ?? {},
lockRoot: options.lockRoot,
retainOnExit: options.retainOnExit,
parsePayload: options.parsePayload,
};
context.held.set(normalizedTargetPath, createdHeld);
Expand Down
7 changes: 7 additions & 0 deletions src/sidecar-lock-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>>
metadata?: Record<string, unknown>;
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;
};
Expand Down
31 changes: 27 additions & 4 deletions src/sidecar-lock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fsSync from "node:fs";
import { FsSafeError } from "./errors.js";
import { sameFileIdentity } from "./file-identity.js";
import {
removeSidecarLockIfUnchanged,
Expand Down Expand Up @@ -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<string, SidecarLockManagerState> {
const globalWithState = globalThis as typeof globalThis & {
[GLOBAL_STATE_KEY]?: Map<string, SidecarLockManagerState>;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 };
Expand All @@ -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,
);
Expand Down Expand Up @@ -247,6 +264,12 @@ export function createSidecarLockManager(key: string) {
async function acquire<TPayload extends Record<string, unknown>>(
options: SidecarLockAcquireOptions<TPayload>,
): Promise<SidecarLockHandle> {
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,
Expand Down
79 changes: 79 additions & 0 deletions test/sidecar-lock-process-exit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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, `
Expand Down Expand Up @@ -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<symbol, unknown>;
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, `
Expand Down