/** * Phase 4a — file-based mutex around the lockfile compare-and-swap * region. See `docs/security-trust-layer/plan.md` §2 Phase 4a for * the full spec. * * Why a mutex on top of fingerprint CAS? Without serialization, two * concurrent writers can both pass the fingerprint check (both reads * see the same bytes), both fast-write atomically, and the second * rename overwrites the first — lost update. The mutex ensures only * one writer is in the CAS region at a time, so the fingerprint * check is a meaningful guard against the rare case where a writer * holding the mutex is somehow bypassed (manual edit while we hold * it, etc.). * * Implementation: * - A mutex file lives next to the lockfile: `cursell.lock.lock`. * Creation goes through a write-tmp-then-link sequence so the * mutex file is **never** observable empty mid-acquire: we write * the body fully into a per-process temp file (O_EXCL+fsync), * then call `link(tmp, mutexPath)` which is atomic and fails * with EEXIST if another acquirer already holds it. A naive * `open(path, 'wx')` followed by `writeFile(body)` was unsafe * because a concurrent acquirer could see the 0-byte file in * the gap, parse-fail, and steal it. * - The body is JSON `{pid, startedAt, token}` where token is a * fresh `randomUUID()`. Release verifies the token before * unlinking — this prevents a process whose PID was reused * after a crash from accidentally releasing someone else's lock. * - "Stale" means `pid` is no longer alive OR `startedAt > 5min` * ago. `cursell unlock` (Phase 4d) is the explicit recovery * command that operators can run when a process crashed without * releasing. * * Non-writable directory handling: if we can't create the mutex file * because the directory is read-only or missing, the wrapper throws * a clear error pointing at the dir. We never fall back to a * different location — the lock-region must be tied to the lockfile * path, not somewhere else. */ export interface MutexFileBody { pid: number; startedAt: number; token: string; } export declare class MutexHeldError extends Error { readonly holder: MutexFileBody; readonly mutexPath: string; constructor(holder: MutexFileBody, mutexPath: string); } export interface MutexHandle { /** Path of the mutex file (`.lock`). */ readonly mutexPath: string; /** * Releases the mutex by unlinking the file IF AND ONLY IF the file * still contains our token. Safe to call from a finally block * even if the lock wasn't ours (no-op in that case). */ release(): Promise; /** * Returns true iff the on-disk mutex file STILL contains our * token. False indicates a stale-takeover happened (our holder * was deemed stale and a stealer replaced our entry). Callers * doing long-running operations should call this before any * persistence step to detect lost ownership and abort. * * Residual TOCTOU window between this check and a follow-up * persistence call is irreducible on POSIX. Use immediately * before the persistence call to keep the window small. */ ownershipStillHeld(): Promise; } declare function mutexPathFor(lockfilePath: string): string; /** * Internal alive check; goes through `__testing.isProcessAliveImpl` * so tests can inject a fake without monkey-patching `process.kill`. */ declare function isProcessAlive(pid: number): boolean; export interface AcquireOptions { /** Override the mutex path (defaults to `.lock`). */ mutexPath?: string; /** Total time to wait for the mutex before throwing. Default 30s. */ timeoutMs?: number; /** Sleep between acquire attempts. Default 100ms. */ pollIntervalMs?: number; } /** * Acquire the mutex. Blocks (polling) up to `timeoutMs` for an * existing holder to release; if the holder is stale (dead PID or * older than 5 min) we silently steal the mutex by unlinking and * recreating with our token. * * Throws `MutexHeldError` if a live holder doesn't release within * `timeoutMs`. The caller is responsible for calling `release()` in * a finally block. */ export declare function acquireMutex(lockfilePath: string, options?: AcquireOptions): Promise; /** * Force-clear a mutex file IF it is stale (dead PID or older than * 5 min). Used by `cursell unlock` (Phase 4d). Returns true if a * stale mutex was cleared, false if no mutex file existed, and * throws `MutexHeldError` if the mutex is held by an active process. * * Residual TOCTOU note: between the final ownership check and the * `unlink` syscall, a third process could theoretically replace * the path. POSIX does not provide a "compare-and-delete" * primitive, so this window is irreducible. We narrow it as much * as possible (the ownership check is the very last thing before * unlink) and accept that an extreme race could see `forceUnlock` * remove a freshly-acquired live mutex. The next concurrent * acquirer would observe EEXIST on its `link()` and retry; the * net effect is bounded delay, never silent data loss. */ export declare function forceUnlock(lockfilePath: string): Promise; /** Exported for tests. */ export declare const __testing: { STALE_MS: number; mutexPathFor: typeof mutexPathFor; isProcessAlive: typeof isProcessAlive; /** Inject a fake `isProcessAlive` for tests; pass `null` to restore. */ setIsProcessAliveForTests(impl: ((pid: number) => boolean) | null): void; /** * Inject a hook that fires inside `forceUnlock` between the * initial read and the final ownership check, simulating a * concurrent stale-takeover or fresh-acquire. Pass `null` to * restore. Production code path: hook is null, no-op. */ setForceUnlockBeforeFinalCheckHookForTests(hook: ((mutexPath: string) => Promise) | null): void; }; export {};