import * as crypto from "node:crypto" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" import { Effect } from "effect" import { ConfigError, OperationLocked } from "../errors.ts" type Owner = { readonly pid: number readonly token: string } const OWNER_FILE = "owner.json" const OWNER_LIMIT = 4 * 1024 function lockDirectory(): string { const identity = typeof process.getuid === "function" ? process.getuid() : "user" return path.join(os.tmpdir(), `apnea-${identity}`, "operation-locks") } function canonicalRepository(root: string): string { return fs.realpathSync(root) } export function repositoryLockPath(root: string): string { const canonical = canonicalRepository(root) const key = crypto .createHash("sha256") .update(`repository\0${canonical}`) .digest("hex") return path.join(lockDirectory(), `repository-${key}.lock`) } export function globalSetupLockPath(accountHome: string): string { const canonical = fs.realpathSync(accountHome) const key = crypto .createHash("sha256") .update(`global-setup\0${canonical}`) .digest("hex") return path.join(lockDirectory(), `global-setup-${key}.lock`) } function ensureLockDirectory(directory: string): void { for (const component of [path.dirname(directory), directory]) { try { fs.mkdirSync(component, { mode: 0o700 }) } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error } const stat = fs.lstatSync(component) if (stat.isSymbolicLink() || !stat.isDirectory()) { throw new ConfigError({ message: `unsafe Apnea lock directory: ${component}`, path: component, }) } if (typeof process.getuid === "function" && stat.uid !== process.getuid()) { throw new ConfigError({ message: `Apnea lock directory is not owned by the current user: ${component}`, path: component, }) } fs.chmodSync(component, 0o700) } } function isCurrentUser(stat: fs.Stats): boolean { return typeof process.getuid !== "function" || stat.uid === process.getuid() } function hasPrivateMode(stat: fs.Stats): boolean { return process.platform === "win32" || (stat.mode & 0o077) === 0 } function readOwner(lock: string): Owner | null { let descriptor: number | undefined try { const lockStat = fs.lstatSync(lock) if ( lockStat.isSymbolicLink() || !lockStat.isDirectory() || !isCurrentUser(lockStat) || !hasPrivateMode(lockStat) ) { return null } const ownerPath = path.join(lock, OWNER_FILE) if (fs.lstatSync(ownerPath).isSymbolicLink()) return null descriptor = fs.openSync( ownerPath, process.platform === "win32" ? "r" : fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK, ) const ownerStat = fs.fstatSync(descriptor) if ( !ownerStat.isFile() || !isCurrentUser(ownerStat) || !hasPrivateMode(ownerStat) || ownerStat.size <= 0 || ownerStat.size > OWNER_LIMIT ) { return null } const bytes = Buffer.alloc(ownerStat.size) let offset = 0 while (offset < bytes.length) { const read = fs.readSync( descriptor, bytes, offset, bytes.length - offset, offset, ) if (read === 0) return null offset += read } const value = JSON.parse(bytes.toString("utf8")) as unknown if ( typeof value === "object" && value !== null && "pid" in value && typeof value.pid === "number" && Number.isInteger(value.pid) && value.pid > 0 && "token" in value && typeof value.token === "string" && value.token.length > 0 ) { return { pid: value.pid, token: value.token } } } catch { // Malformed ownership is never safe to remove automatically. } finally { if (descriptor !== undefined) fs.closeSync(descriptor) } return null } function processIsAlive(pid: number): boolean { try { process.kill(pid, 0) return true } catch (error) { return (error as NodeJS.ErrnoException).code !== "ESRCH" } } function pathExistsNoFollow(target: string): boolean { try { fs.lstatSync(target) return true } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false throw error } } function fsyncDirectory(directory: string): void { const descriptor = fs.openSync(directory, fs.constants.O_RDONLY) try { try { fs.fsyncSync(descriptor) } catch (error) { const code = (error as NodeJS.ErrnoException).code const unsupported = ["EINVAL", "ENOTSUP", "EOPNOTSUPP"].includes(code ?? "") || (process.platform === "win32" && ["EISDIR", "EPERM"].includes(code ?? "")) if (!unsupported) throw error } } finally { fs.closeSync(descriptor) } } function writeCandidate(directory: string, owner: Owner): void { fs.mkdirSync(directory, { mode: 0o700 }) const ownerPath = path.join(directory, OWNER_FILE) const descriptor = fs.openSync(ownerPath, "wx", 0o600) try { fs.writeFileSync(descriptor, JSON.stringify(owner), "utf8") fs.fsyncSync(descriptor) } finally { fs.closeSync(descriptor) } fsyncDirectory(directory) } // The caller either releases its own live owner or holds the reclamation guard. // A live owner cannot be reclaimed. Stale removers must serialize the token // check and rename; checking the token after rename cannot undo displacement. function moveOwnedToTombstone(lock: string, token: string): string | null { if (readOwner(lock)?.token !== token) return null const tombstone = `${lock}.tombstone.${crypto.randomUUID()}` try { fs.renameSync(lock, tombstone) } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null throw error } if (readOwner(tombstone)?.token !== token) return null return tombstone } function removeIfOwned(lock: string, token: string): void { const tombstone = moveOwnedToTombstone(lock, token) if (tombstone !== null) { fs.rmSync(tombstone, { recursive: true, force: true }) } } function acquireLock( lock: string, resource: string, reclaim?: { graceMs: number; now: () => number }, ): { lock: string; owner: Owner } { ensureLockDirectory(path.dirname(lock)) for (let attempt = 0; attempt < 3; attempt++) { const owner = { pid: process.pid, token: crypto.randomUUID() } const candidate = `${lock}.candidate.${owner.token}` let contended = false try { writeCandidate(candidate, owner) if (pathExistsNoFollow(lock)) { contended = true } else { try { fs.renameSync(candidate, lock) } catch (error) { if ( !["EEXIST", "ENOTEMPTY", "EPERM"].includes( (error as NodeJS.ErrnoException).code ?? "", ) ) { throw error } contended = true } } } finally { fs.rmSync(candidate, { recursive: true, force: true }) } if (!contended) return { lock, owner } if (!pathExistsNoFollow(lock)) continue const existing = readOwner(lock) if (existing === null) { throw new OperationLocked({ message: `Apnea lock metadata is malformed at ${lock}. ` + `Automatic cleanup is disabled. Verify no Apnea process owns it, then Remove this lock directory manually: ${lock}`, repository: resource, lock_path: lock, reason: "malformed", pid: 0, }) } if (processIsAlive(existing.pid)) { throw new OperationLocked({ message: `another Apnea operation holds ${lock} for ${resource} (pid ${existing.pid})`, repository: resource, lock_path: lock, reason: "live", pid: existing.pid, }) } if ( reclaim !== undefined && lockAgeMs(lock, reclaim.now()) >= reclaim.graceMs && removeStaleOwner(lock, existing.token, resource) ) { // Dead owner past the freshness grace: a crashed holder. Reclaiming // lets crash-recoverable operations (e.g. a durable commit // transaction) resume instead of wedging behind manual cleanup. continue } throw new OperationLocked({ message: `Apnea lock owner pid ${existing.pid} is not live at ${lock}. ` + `Automatic stale cleanup is disabled. Verify no Apnea process owns it, then Remove this lock directory manually: ${lock}`, repository: resource, lock_path: lock, reason: "stale", pid: existing.pid, }) } throw new OperationLocked({ message: `Apnea lock changed repeatedly at ${lock}; retry the operation`, repository: resource, lock_path: lock, reason: "raced", pid: 0, }) } /** Age of the lock directory in ms against the injected clock. */ function lockAgeMs(lock: string, nowMs: number): number { try { return Math.max(0, nowMs - fs.lstatSync(lock).mtimeMs) } catch { return 0 } } /** * Only one stale remover may validate ownership and rename the canonical path. * Publication needs no guard: the old nonempty directory excludes candidates * until rename, and this remover never renames the canonical path again. * A delayed remover must acquire the guard and recheck the token, so it cannot * displace a replacement. Live-owner release cannot race a matching stale * removal because processIsAlive refuses that owner. * * The guard is deliberately not reclaimable. A crash here requires manual * removal of `${lock}.reclaim` after all Apnea processes using this lock stop. * Recursively reclaiming a stale guard would reintroduce the same race. */ function removeStaleOwner( lock: string, token: string, resource: string, ): boolean { const guard = `${lock}.reclaim` try { fs.mkdirSync(guard, { mode: 0o700 }) } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error throw new OperationLocked({ message: `Apnea stale-lock reclamation is guarded at ${guard}. Retry after the other operation completes. ` + `If the guard persists, stop all Apnea processes using ${lock}, then remove this guard directory manually: ${guard}`, repository: resource, lock_path: lock, reason: "stale", pid: 0, }) } try { const tombstone = moveOwnedToTombstone(lock, token) if (tombstone === null) return false fs.rmSync(tombstone, { recursive: true, force: true }) return true } finally { fs.rmdirSync(guard) } } function withLock( lock: string, resource: string, operation: Effect.Effect, waitForRetry?: Effect.Effect, reclaim?: { graceMs: number; now: () => number }, ): Effect.Effect { const acquireOnce = () => Effect.try({ try: () => acquireLock(lock, resource, reclaim), catch: (error) => error instanceof OperationLocked || error instanceof ConfigError ? error : new ConfigError({ message: `could not acquire Apnea operation lock: ${error instanceof Error ? error.message : String(error)}`, path: resource, }), }) const acquireEffect = (): Effect.Effect< { lock: string; owner: Owner }, OperationLocked | ConfigError > => acquireOnce().pipe( Effect.catch((error) => error instanceof OperationLocked && error.reason === "live" && waitForRetry ? waitForRetry.pipe(Effect.andThen(acquireEffect())) : Effect.fail(error), ), ) return Effect.acquireUseRelease( acquireEffect(), () => operation, ({ lock: ownedLock, owner }) => Effect.sync(() => removeIfOwned(ownedLock, owner.token)), ) } /** How long a dead owner's lock must sit untouched before reclaim is safe. */ export const REPOSITORY_LOCK_RECLAIM_GRACE_MS = 60_000 export type RepositoryLockOptions = { /** * Grace period (ms) a stale lock must age past before a dead owner is * reclaimed. Mitigates PID reuse: a recycled pid looks alive anyway, and * an old lock directory means no live process refreshed it. */ readonly staleGraceMs?: number /** Clock seam for tests; defaults to Date.now. */ readonly now?: () => number } export function withRepositoryLock( root: string, operation: Effect.Effect, options: RepositoryLockOptions = {}, ): Effect.Effect { const repository = canonicalRepository(root) return withLock( repositoryLockPath(repository), repository, operation, undefined, { graceMs: options.staleGraceMs ?? REPOSITORY_LOCK_RECLAIM_GRACE_MS, now: options.now ?? (() => Date.now()), }, ) } export function withGlobalSetupLock( accountHome: string, operation: Effect.Effect, waitForRetry: Effect.Effect = Effect.sleep(25), ): Effect.Effect { const home = fs.realpathSync(accountHome) return withLock( globalSetupLockPath(home), `global setup at ${home}`, operation, waitForRetry, ) } /** Global setup lock is always outermost; repository lock is optional and inner. */ export function withSetupLocks( accountHome: string, root: string, lockRepository: boolean, operation: Effect.Effect, waitForRetry?: Effect.Effect, ): Effect.Effect { return withGlobalSetupLock( accountHome, lockRepository ? withRepositoryLock(root, operation) : operation, waitForRetry, ) }