// lib/dir-lock.mjs — the one cross-process directory lock. // // Every core lock (data/pipeline.md, the applications tracker, follow-ups.md, // portal-health.tsv) is this protocol with a different path, owner stamp and // timeout error. Each used to carry its own acquire/recover/release body, and // the copies drifted apart one fix at a time: #2777 (Windows EPERM) was learned // by one copy at a time, #2506 and #2835 (lockstep wakeups) reached three of // them late, #3895 found "a fourth copy of that bound", and #5358 found copies // that deleted a peer's lock on a failed owner write, crashed or deadlocked on // one, or skipped the identity recheck and the stamp read-back pipeline-lock // had added after measuring the race. So the whole protocol lives here, and // the callers only pick what differs. tests/lock-rm-contention.test.mjs fails // if an acquire loop of its own appears anywhere else. // // web/src/lib/core/dir-lock.mjs is a byte-for-byte copy of this file, because // the web bundle cannot import the repo-root core (web/next.config.mjs pins the // Turbopack root to web/). web/tests/lib/dir-lock.test.mjs fails if the two // differ by a single byte, so edit this file and copy it across, never the // other way round. // // Protocol: // - the lock is a directory (".lock"); a mkdir is atomic. // - the holder records owner.json — pid, a unique token, started_at — so // both stale-reclaim and release can verify who actually owns the lock // before deleting anything. // - staleness is judged by owner-PID liveness first, falling back to // directory age only when the metadata is missing or unreadable. An old // lock whose owner is still running is NOT stale, and an ownerless // directory gets a fixed grace period (OWNERLESS_GRACE_MS) before age // alone can condemn it, so a directory created microseconds ago is never // reclaimable no matter how aggressive the caller's staleMs. // - stale reclamation is serialized behind a second atomic guard directory // (".lock.recover"). Without it, reclamation is itself a TOCTOU // race: two callers that both judge the same lock stale can have the // second one's rmSync delete the first one's freshly created lock, after // which both believe they hold it — reintroducing the very race the lock // exists to close, just gated behind a crash + contention window. // // Timing is caller-supplied so tests can exercise contention in milliseconds // instead of waiting out a multi-second constant. import { mkdirSync, rmSync, statSync, existsSync, writeFileSync, readFileSync } from 'fs'; import { join, dirname } from 'path'; import { randomUUID } from 'crypto'; // Env-overridable like the timing knobs each caller reads (staleMs, timeoutMs, // retryMs) for the same reason: a test that backdates a lock by a small, // fixed amount to stay just past a tiny staleMs needs the OTHER side of that // window — the floor itself — to have slack too, or the real cost of spawning // the child process (Node startup, the module graph, retries) can eat into the // 1s default before the child ever looks. On a slower CI runner that reads // as the lock "aging out" prematurely and the test asserting the opposite of // what it measured (#4537) — read once at module load, which is fine since // every caller is a fresh process. // // Only a finite, positive override is honoured. `|| 1_000` alone would accept // a NEGATIVE value (a truthy number, so it survives the `||`) and reduce the // floor below 1,000ms — the opposite of every legitimate use, which widens // it. It would also accept `Infinity`, which stops ownerless locks from ever // aging out and can hang acquisition on a genuinely abandoned lock. Both are // clearly misconfiguration, not a caller asking for "no floor"; unlike // acquireDirLock's `maxWaitMs`, this constant has no documented // Infinity-means-unbounded convention to preserve. const configuredOwnerlessGraceMs = Number(process.env.CAREER_OPS_OWNERLESS_GRACE_MS); export const OWNERLESS_GRACE_MS = Number.isFinite(configuredOwnerlessGraceMs) && configuredOwnerlessGraceMs > 0 ? configuredOwnerlessGraceMs : 1_000; // Ceiling on progress-extended waiting (see createLockWaitPolicy). A multiple // of timeoutMs rather than a constant, so a caller that tunes one tunes both. const DEFAULT_MAX_WAIT_FACTOR = 10; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // The same pause for tryAcquireDirLockSync, which has no event-loop turn to // give back. It only ever waits out an inconclusive stamp read-back, below. const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); // How long acquisition keeps re-reading its own stamp when the read fails // with something other than ENOENT: a handful of short tries, enough to ride // out a transient Windows sharing violation without stretching acquisition. const READBACK_RETRIES = 5; const READBACK_RETRY_MS = 10; /** * Owner metadata for a lock directory, plus whether its ABSENCE is a fact. * * `inspected` is the half that matters for safety, and it is the same * distinction #2984 drew for `statSync` one line further down — applied to the * read that happens first. ENOENT is a FACT: there is genuinely no stamp, which * happens by construction for a lock between its mkdir and its owner.json * write, and for the recover guard, which never carries one. The age rule is * the right judge for both. * * Any other code is not a fact. On Windows a live holder's stamp is momentarily * unreadable while the directory is churning under concurrent waiters, and * collapsing that into the same `null` as "no stamp" lets the age rule condemn * a lock that is very much alive. Absence we established, versus absence we * merely failed to observe. * * ENOTDIR is the same fact for a path that is not a directory at all: there is * no stamp because there is nowhere for one to be. A lock path holding a plain * FILE is never a live lock under this protocol. It is a leftover, typically * from a caller that locked with an O_EXCL file at the same name before moving * here (the web's assistant chat store did, #5358). Reading it as "could not * look" would pin it LIVE for ever and wedge every acquirer; reading it as * ownerless hands it to the age rule, which reclaims it once it is old enough. */ export function readLockOwner(lockDir) { let raw; try { raw = readFileSync(join(lockDir, 'owner.json'), 'utf-8'); } catch (err) { return { inspected: err?.code === 'ENOENT' || err?.code === 'ENOTDIR', owner: null }; } try { return { inspected: true, owner: JSON.parse(raw) }; } catch { // A torn or corrupt stamp is readable but meaningless. Fall through to the // age rule rather than pinning the lock as unrecoverable for ever. return { inspected: true, owner: null }; } } // stat, or null when the path is gone or unreadable. Callers that are deciding // whether to DELETE something need "I could not establish this" and "it is not // there" to arrive as the same cautious answer. function statOrNull(dir) { try { return statSync(dir); } catch { return null; } } // A stat taken to compare identities: the stats, GONE when the directory does // not exist (ENOENT is a fact), or null when it could not be looked at — no // evidence either way, the same distinction lockRecoveryVerdict draws. const GONE = Symbol('gone'); function statForIdentity(dir) { try { return statSync(dir); } catch (err) { return err?.code === 'ENOENT' ? GONE : null; } } // Whether `now` is the directory `created` was taken from: true, false, or // null when either look failed and the question cannot be answered. function sameAsCreated(created, now) { if (now === GONE) return false; if (created === GONE || created === null || now === null) return null; return sameLockDirectory(created, now); } // Identity of a directory, so a lock that was removed and recreated by another // process is never mistaken for the one this caller created. export function sameLockDirectory(left, right) { return left.dev === right.dev && left.ino === right.ino && (left.ino !== 0 || left.birthtimeMs === right.birthtimeMs); } // mkdir's "someone else already has this" answer is NOT portable. POSIX gives // EEXIST; Windows gives EEXIST *sometimes* and EPERM/EACCES when the target is // mid-flight — being created, or being removed, by another process at that // instant. That is not an error condition here, it is the contention this loop // exists to handle, and it is precisely what a burst of concurrent writers // manufactures. // // Treating it as fatal is how an item gets LOST. Measured on windows-latest // (#2777, run 31745798742): one of 30 concurrent `agent-inbox add` processes // died with `EPERM: operation not permitted, mkdir '…agent-inbox.md.lock.recover'` // and its line was never appended. The two budget increases before this one // (#2506 jitter, #2825's 30s) both moved the symptom without touching this, // because a starving writer and a writer killed by EPERM produce the same // `kept=29 of 30` and only the second is a crash. // // A genuine permissions problem still surfaces: it simply stops being an // instant throw and becomes a timeout that names the last error it saw, which // is the correct trade when the alternative is silent data loss. export function isMkdirContention(err) { return err?.code === 'EEXIST' || err?.code === 'EPERM' || err?.code === 'EACCES'; } // rm's Windows answer under contention mirrors mkdir's. Removing a directory // another process is inside of — mid-mkdir, mid-scan (antivirus, indexer), or // mid-rm — fails with EPERM/EACCES/EBUSY/ENOTEMPTY, and `force: true` only // silences ENOENT. Measured on windows-latest (#2777, run 32044401225): two of // 30 concurrent `agent-inbox add` processes died on // `EPERM … agent-inbox.md.lock.recover` thrown by a bare rmSync of the recover // guard, and their items were never appended — the same loss the mkdir half of // this handling removed, resurfacing one call later. export function isRmContention(err) { return err?.code === 'EPERM' || err?.code === 'EACCES' || err?.code === 'EBUSY' || err?.code === 'ENOTEMPTY'; } // Best-effort removal of a lock artifact (the lock dir or the recover guard). // Contention is swallowed and reported via the return value; anything else // (EROFS/ENOSPC-class breakage) still throws. Never fatal on contention // because every lock artifact ages out: an abandoned guard or lock is // reclaimed by lockRecoveryVerdict on a later attempt, so the worst case of // leaving one behind is one extra retry for some caller. Killing the writer // loses its item; waiting does not. export function rmLockArtifactSync(dir) { try { rmSync(dir, { recursive: true, force: true }); return true; } catch (err) { if (isRmContention(err)) return false; throw err; } } function processIsAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (err) { return err?.code === 'EPERM'; // exists, just not signalable by this user } } // Conservative: a lock whose recorded owner is still running is never stale, // however old it is. Age is the fallback only when there's no readable owner. // // That fallback needs a floor. Two directories are ownerless by construction, // not by accident: a lock between its mkdir and its owner.json write, and the // recover guard, which never carries owner.json at all. Judging those on // `age > staleMs` alone lets a caller with an aggressive staleMs delete a // directory created microseconds ago — either stealing a winner's lock inside // its acquisition window, or evicting a live guard and putting two callers // inside the decide-then-delete window the guard exists to serialize. // OWNERLESS_GRACE_MS is a lower bound on that patience, never a cap: a larger // caller staleMs still wins, and a genuinely abandoned directory still ages // out, so a crash while holding the guard cannot disable recovery for good. // // The answer is a VERDICT, not a boolean, because "recoverable" was two // different answers wearing one hat and only one of them licenses a delete: // // STALE — a directory is THERE and its holder is gone. Deleting it IS the // recovery, and it is the only case that should reach rmSync. // VANISHED — there was nothing there when we looked. Also "not blocked", but // emphatically NOT a licence to delete: the caller acts on this // verdict microseconds later, and by then the path may be owned by // a process that legitimately created it in between. Deleting on // this answer is how an acquirer destroys a live lock it never // observed (see the caller). // LIVE — someone holds it, or we could not establish otherwise. Wait. export const RECOVER_STALE = 'stale'; export const RECOVER_VANISHED = 'vanished'; export const RECOVER_LIVE = 'live'; // Tokens of the handles this module has handed out and not yet released. // // A live pid proves its holder is alive only when the holder is another // process. For THIS process's pid it proves nothing: a long-lived process (the // web server) can be left holding a directory no handle owns — a release whose // rm was contended on Windows (swallowed by design, see createRelease), or a // stamp this caller wrote but could not confirm. The pid rule would then keep // that lock LIVE until the server restarts, answering "busy" to every request // that needs it (#5390 review). So our own pid counts as live only for a token // we are actually holding; any other stamp of ours goes to the age rule below. // // The age rule, not an immediate reclaim, because this set is per module // instance: a second copy of this module in the same process (a bundler chunk, // a worker thread, a dev-server reload) holds tokens this one cannot see. Its // lock is reclaimable only once it is older than staleMs, which no critical // section in this repo approaches. const activeTokens = new Set(); export function lockRecoveryVerdict(lockDir, staleMs) { const { inspected, owner } = readLockOwner(lockDir); // Unreadable is not ownerless. A stamp this process could not read proves // nothing about whether its holder is alive, and falling through to the age // rule on that basis is how a LIVE lock gets condemned — the same reasoning // #2984 applied to the stat below, one step earlier in the same function. if (!inspected) return RECOVER_LIVE; if (owner?.pid) { if (!processIsAlive(owner.pid)) return RECOVER_STALE; if (owner.pid !== process.pid || activeTokens.has(owner.token)) return RECOVER_LIVE; // Our pid, but no handle of ours: fall through to the age rule. } try { return Date.now() - statSync(lockDir).mtimeMs > Math.max(staleMs, OWNERLESS_GRACE_MS) ? RECOVER_STALE : RECOVER_LIVE; } catch (err) { // Any stat failure other than ENOENT — Windows EPERM/EBUSY while the // directory is mid-flight — is "could not look", and answering // "recoverable" to that hands the caller an rmSync of a LIVE lock created // microseconds ago: its winner then dies with ENOENT writing owner.json // (#2777, third face — measured after the rm-contention fix exposed it). return err?.code === 'ENOENT' ? RECOVER_VANISHED : RECOVER_LIVE; } } /** * The waiting half of the protocol. * * Both rules here were bought with measured failures in `pipeline-lock`, and * for a long time neither reached the other lock modules, which slept a FIXED * `retryMs` and timed out on a plain elapsed check: * * - jitter (#2506). A fixed retry wakes every waiter at the same instant to * re-race, which is the coupon-collector problem: serving N waiters takes * about N·H(N) rounds, and the loser's write is lost. Measured at 20 runs * per arm, a fixed retry lost an item in 12 of 20 against 2 of 20 jittered. * - the progress rule (#2835). An expired deadline only means "give up" when * the lock has NOT changed hands since we last looked. Without it a caller * waiting on a healthy, briskly handed-round lock is killed for being * unlucky rather than for anything being stuck. * * Returned as closures over one caller's state because both rules are * stateful: the backoff needs the ceiling, and the progress rule needs the * previous fingerprint and a re-armable window. * * @param {string} lockDir - The lock directory this caller is waiting on. * @param {{timeoutMs: number, retryMs: number, deadline: number, hardDeadline?: number}} timing * `hardDeadline` is OPTIONAL: omit it and the policy applies its own default * ceiling, DEFAULT_MAX_WAIT_FACTOR x timeoutMs from now. Pass one only when * the caller has a real knob of its own (acquireDirLock's `maxWaitMs`). * @returns {{backoffMs: () => number, holderStillWedged: () => boolean, noteWaiting: () => void, ceilingReached: () => boolean}} */ export function createLockWaitPolicy(lockDir, { timeoutMs, retryMs, deadline, hardDeadline }) { let perHolderDeadline = deadline; let lastFingerprint; // The ceiling is the policy's, not each caller's (#3895). Three lock modules // used to write `Date.now() + timeoutMs * 10` at their own call sites, so the // bound the clamp below applies existed in four places. Copies of an // invariant stay correct only until one is edited, and a wrong ceiling fails // silently — it changes retry timing, which nothing asserts and nobody // reports. Deriving it here leaves one copy for them all. const ceiling = hardDeadline ?? Date.now() + timeoutMs * DEFAULT_MAX_WAIT_FACTOR; // A ceiling that is not a number is not a ceiling, and it fails INVISIBLY: // Math.min(x, NaN) is NaN, Math.max(0, NaN) is NaN, and setTimeout(NaN) fires // immediately — so a caller that got this wrong would spin hot through the // retry loop rather than raise anything. Infinity is a deliberate, legible // "no ceiling" (acquireDirLock passes it for maxWaitMs: Infinity) and is // kept; NaN and non-numbers are mistakes and are refused where they are made. if (typeof ceiling !== 'number' || Number.isNaN(ceiling)) { throw new TypeError( `createLockWaitPolicy: hardDeadline must be a number or omitted, got ${String(hardDeadline)}`, ); } // Jittered backoff, never sleeping past EITHER deadline the loop can exit on. // // ceiling: an uncapped sleep can cross it and let the NEXT mkdir succeed, // returning a lock after the documented absolute limit — an overshoot of up // to 1.5x retryMs. Waking exactly at the ceiling means the check at the top // of the loop is what decides, rather than whichever of the two happened to // be later. // // perHolderDeadline: the loop also exits when holderStillWedged() flips true, // which cannot happen until this deadline passes. Sleeping past it means the // caller's own timeoutMs is only observed at whatever moment the sleep // happens to end. With retryMs above timeoutMs the overshoot is the whole // retry: a 150ms timeout waited five seconds. Waking at the nearer deadline // puts the decision back in the check at the top of the loop. // // Clamping OUTSIDE this function is not equivalent. A caller clamping to its // own fixed `startedAt + timeoutMs` goes to zero once that passes, while // holderStillWedged() RE-ARMS perHolderDeadline every time the lock changes // hands. The loop then legitimately continues to the ceiling while every // sleep is 0, which is a busy-wait at the setTimeout floor and the thundering // herd the jitter above exists to prevent. const backoffMs = () => Math.max(0, Math.min( retryMs * (0.5 + Math.random()), ceiling - Date.now(), perHolderDeadline - Date.now(), )); // The per-holder deadline, evaluated the SAME way everywhere: an expired // deadline only means "give up" when the lock has not changed hands since we // last looked. Otherwise the window is re-armed and the caller waits again. // // A helper rather than inline code because three separate paths can time a // caller out — the main retry, a non-EEXIST guard refusal, and an ENOENT // owner-write retry — and the progress rule holding on one while the other // two throw directly is the same bug in two more places: a healthy lock // being handed round briskly could still kill a caller through them. const holderStillWedged = () => { if (Date.now() <= perHolderDeadline) return false; const fingerprint = lockFingerprint(lockDir); // Only a lock we can SEE, unchanged across a full window, is evidence that // waiting longer is futile. A fingerprint that moved means the lock is // being handed round. A null one means it was free or unobservable at the // instant we looked — no evidence either way, and not to be mistaken for a // wedged holder. if (fingerprint !== null && fingerprint === lastFingerprint) return true; lastFingerprint = fingerprint; perHolderDeadline = Date.now() + timeoutMs; return false; }; // Sampled lazily on the first failed acquisition, not at construction, so the // very first window is measured against the state the caller actually started // waiting on. Without it the first expiry compares against `undefined`, // always re-arms, and every caller gets one free window it did not ask for. const noteWaiting = () => { if (lastFingerprint === undefined) lastFingerprint = lockFingerprint(lockDir); }; // The absolute ceiling, exposed because holderStillWedged() RE-ARMS on // progress: a lock being handed round briskly resets the window every time, // so the progress rule alone never terminates. acquireDirLock checks this at // the top of its loop; the lock modules that once had their own loops had no // ceiling at all before they adopted the progress rule, and adopting it // without this would trade a premature timeout for an unbounded wait. const ceilingReached = () => Date.now() > ceiling; return { backoffMs, holderStillWedged, noteWaiting, ceilingReached }; } // A cheap identity for "which holder currently has this lock". Used only to // tell a lock that is CHANGING HANDS (the system is making progress; this // caller is merely unlucky) from one that is WEDGED (a single holder is not // letting go, which is what the timeout exists to escape). function lockFingerprint(lockDir) { try { const st = statSync(lockDir); const { owner } = readLockOwner(lockDir); // ino is 0 on some Windows volumes, hence birthtime as the tiebreaker — // the same pairing sameLockDirectory() already relies on. return `${owner?.token ?? ''}|${st.ino}|${st.birthtimeMs}`; } catch { return null; // free or unobservable — no evidence either way } } /** * Error thrown when a lock stays busy and the caller supplied no `timeoutError`. * Callers with an established error type of their own pass a factory instead. */ export class DirLockTimeoutError extends Error { constructor(lockDir, expiredMs) { super(`lock timeout: ${lockDir} held > ${expiredMs}ms`); this.name = 'DirLockTimeoutError'; this.code = 'LOCK_TIMEOUT'; this.lockDir = lockDir; } } // What one pass of the protocol can end in. The waiting caller // (acquireDirLock) and the non-waiting one (tryAcquireDirLockSync) differ only // in what they do with these, so the pass itself is written once. // STAMPED — this pass created the lock and wrote its stamp; confirmStamp() // decides whether it is still ours. // RECLAIMED — a stale lock was removed under the recover guard: try again now. // LOST_RACE — the lock this pass created was taken from it before it could // be handed out: try again now, unless the holder is wedged. // BUSY — someone else holds it, or the guard is mid-flight: back off. const PASS_STAMPED = 'stamped'; const PASS_RECLAIMED = 'reclaimed'; const PASS_LOST_RACE = 'lost-race'; const PASS_BUSY = 'busy'; // The non-waiting caller's bound on immediate retries. A reclaim and a lost race // each earn one more pass; anything still contended after that is a holder, and // the honest answer is "busy". const SYNC_MAX_PASSES = 3; /** * One acquisition attempt: mkdir, and on contention a guarded stale-reclaim; * on success, the owner stamp. Never sleeps. Returns a PASS_* outcome; on * PASS_STAMPED the caller runs confirmStamp() before handing out a lock. * * `state` carries what persists across passes for one caller: its token, the * attempt count, whether it reclaimed a stale lock, the last mkdir error it read * as contention, the identity of the directory this pass created, and * `onContention`, the waiting caller's hook for sampling the lock it started * waiting on. */ function attemptDirLock(lockDir, state, options) { const { staleMs } = options; const recoverGuardDir = `${lockDir}.recover`; state.attempts++; try { mkdirSync(lockDir); } catch (err) { // Not just EEXIST: Windows reports a directory that is mid-create or // mid-remove by another process as EPERM/EACCES. That is contention, not // failure — treating it as fatal kills a concurrent writer and loses its // write (#2777, measured on windows-latest). if (!isMkdirContention(err)) throw err; state.lastContentionError = err; state.onContention(); // Serialize stale-reclaim behind a second atomic guard so only one // caller can be inside the decide-then-delete window at a time. let hasRecoverGuard = false; try { mkdirSync(recoverGuardDir); hasRecoverGuard = true; } catch (guardErr) { if (!isMkdirContention(guardErr)) throw guardErr; state.lastContentionError = guardErr; // An EPERM/EACCES here says the guard directory is mid-flight, not that // it is sitting there abandoned, so the age check below would be // reasoning about a directory it cannot even stat reliably. Back off to // the retry loop instead of judging it. if (guardErr.code !== 'EEXIST') return PASS_BUSY; // A process killed between taking the guard and cleaning it up would // otherwise disable stale recovery forever. The guard normally lives // for milliseconds, so an old one is judged by the same age rule. // STALE only. A guard that was already gone when we looked needs no // eviction, and evicting on that answer would delete the guard a // different caller has just legitimately taken — putting two callers // inside the decide-then-delete window this guard exists to serialize. if (lockRecoveryVerdict(recoverGuardDir, staleMs) === RECOVER_STALE) { rmLockArtifactSync(recoverGuardDir); } } if (hasRecoverGuard) { try { options.onRecoverGuard?.(); } catch { /* an observational hook must never change acquisition */ } try { // Only STALE reaches the rm. VANISHED means the lock was absent when // we looked, and the gap between that observation and this line is // long enough for another acquirer to have won the mkdir and be // partway through writing its owner.json. Deleting on that answer // destroys the fresh, live lock: measured on Windows, the winner's // mkdir landed 472 us after the vanished verdict and the rm 68 us // after that, killing it with `ENOENT ... open '.lock/owner.json'` // and losing its queued item. The rm is the lucky outcome — had the // stamp landed first, both callers would have held the lock with no // error at all. // // So VANISHED falls through to the normal backoff and retries // acquisition. It costs one backoff in a rare case; deleting a live // lock costs an item. // // STALE is not enough on its own either: it says the directory we // READ was abandoned, and the rm acts on whatever is at the path a // moment later. Reaching the verdict costs a readFileSync and a // process.kill, which is ample room to be descheduled, and in that // gap the stale lock can be reclaimed by someone else and replaced by // a live one. Deleting then destroys a lock this caller never judged. // // Not hypothetical. Logging the directory's inode either side of the // verdict, the ONE stale reclaim in a 2,400-acquisition run came back // idBefore=5910974513639709 ownerBefore=39392 // idAfter =6192449490350378 ownerAfter =27256 alive=true // — a different directory, owned by a live process, deleted anyway. // // So the identity of the judged directory is carried to the rm and // rechecked. A path that changed underneath us is left alone; we go // back and compete for it normally. const judged = statOrNull(lockDir); if (judged && lockRecoveryVerdict(lockDir, staleMs) === RECOVER_STALE && sameLockDirectory(judged, statOrNull(lockDir) ?? {})) { if (rmLockArtifactSync(lockDir)) { state.staleRecovered = true; return PASS_RECLAIMED; // retry acquisition immediately, still holding the guard's decision } // rm hit contention: another process is touching the stale lock at // this instant, so fall through to the normal backoff instead of // treating the collision as fatal. } } finally { rmLockArtifactSync(recoverGuardDir); } } return PASS_BUSY; } // The directory we just won, by identity. confirmStamp() checks it // alongside the stamp, and falls back on it alone when the stamp cannot be // read at all. state.created = statForIdentity(lockDir); // Acquired. Record ownership; an owner-less lock would block every future // acquirer until the age-out, so clean up if the stamp can't be written. // Its own try, NOT the mkdir's: a failure here is about a directory this // caller created, and the mkdir catch above would read EPERM/EACCES from // the write as contention and wait on our own ownerless lock (#5358). try { writeFileSync(join(lockDir, 'owner.json'), JSON.stringify({ pid: process.pid, token: state.token, started_at: new Date().toISOString(), ...options.owner, }, null, 2), { flag: 'wx' }); } catch (ownerErr) { // ENOENT writing owner.json means the just-won lock directory is gone: // another caller (mis)judged it reclaimable and deleted it. That is a // lost race, not a failure — re-enter the loop and compete again. // Dying here loses the caller's queued write (#2777, third face). And // deleting here would be worse: the path may already hold a NEWER // caller's live lock, which an rm would free underneath it (#5358). // // EEXIST is the same race seen from the other side. The directory we // created was empty, so a stamp already in it means our directory was // replaced by another caller's before we wrote. The exclusive write is // what keeps us from overwriting that holder's stamp: a plain write would // put our token in its lock, our read-back would then accept it, and two // processes would enter the critical section (#5389 review). if (ownerErr?.code === 'ENOENT' || ownerErr?.code === 'EEXIST') return PASS_LOST_RACE; // Best-effort: a contended rm here must not mask ownerErr, and an // orphaned owner-less lock ages out via lockRecoveryVerdict anyway. // // The try/catch is what makes that sentence true for EVERY failure, not // just a contended one. rmLockArtifactSync deliberately rethrows the // non-contention class (EROFS/ENOSPC), so without this an unwritable // filesystem would propagate from the CLEANUP and the `throw ownerErr` // below would never run — the caller would be told the lock could not be // removed, and never learn why the owner stamp could not be written. // A leftover lock ages out; a swallowed cause is recovered by nothing. try { rmLockArtifactSync(lockDir); } catch { /* cleanup must never outrank the reason we are here */ } throw ownerErr; } return PASS_STAMPED; } /** * Read our own stamp back before handing the caller a lock handle. A generator * so the retry schedule is written once for both entry points: it yields each * pause and returns whether the lock is still ours. acquireDirLock awaits the * pauses (settleAsync); tryAcquireDirLockSync blocks on them (settleSync). * * The identity recheck in attemptDirLock stops this caller from deleting a * directory it did not judge, but it cannot stop another caller from doing so * to US, and that failure has no error attached to it: the delete lands after * our owner.json write succeeds, so nothing in the acquisition path notices and * two processes enter the critical section believing they hold it. * * The ENOENT branch of the owner write recovers the case where the delete * lands BEFORE the stamp. This is the same recovery for the case where it lands * just after. A stamp that is missing, or that carries someone else's token, * means we no longer hold what we took, so we compete again rather than * proceed. * * A read that FAILED (not ENOENT) is neither answer. On Windows a live * holder's stamp is momentarily unreadable while waiters churn the * directory (see readLockOwner), and treating that as "lost" sends this * caller back to wait on its own lock: the mkdir meets EEXIST, the verdict * finds our own live pid and answers LIVE, and the caller times out while * the orphan it leaves blocks every writer until this process exits. So an * inconclusive read is retried briefly, and if it never becomes readable * the directory's identity decides: a replacement lock is a new directory. * Identity is the fallback, not the rule, because a filesystem can reuse * an inode number for a directory recreated at once, which only the stamp * can tell apart. */ function* confirmStamp(lockDir, state) { let readBack = readLockOwner(lockDir); for (let retry = 0; !readBack.inspected && retry < READBACK_RETRIES; retry++) { yield READBACK_RETRY_MS; readBack = readLockOwner(lockDir); } // A readable stamp with our token must also sit in the directory we // created. The exclusive write already refuses another holder's stamp, so // this is the second guard on the same race, not the only one. A stat that // merely failed is no evidence against us (the Windows churn again), so it // only blocks us when the identity is known to differ or the directory is // known to be gone. const identity = sameAsCreated(state.created, statForIdentity(lockDir)); return readBack.inspected ? readBack.owner?.token === state.token && identity !== false : identity === true; } async function settleAsync(steps) { let step = steps.next(); while (!step.done) { await sleep(step.value); step = steps.next(); } return step.value; } function settleSync(steps) { let step = steps.next(); while (!step.done) { sleepSync(step.value); step = steps.next(); } return step.value; } function newAttemptState(onContention = () => {}) { return { token: randomUUID(), attempts: 0, staleRecovered: false, lastContentionError: null, created: null, onContention }; } function lockHandle(lockDir, state, startedAt, options) { activeTokens.add(state.token); return { lockDir, token: state.token, attempts: state.attempts, waitMs: Date.now() - startedAt, staleRecovered: state.staleRecovered, release: createRelease(lockDir, state.token, options.removeLock), }; } /** * Blocks until the directory lock at `lockDir` is held, then returns a handle * whose release() frees it. * * @param {string} lockDir - The lock directory itself (not the file it guards). * @param {object} options * @param {number} options.timeoutMs - Max time a SINGLE holder may block this caller. Waiting is extended while the lock keeps changing hands (see createLockWaitPolicy). * @param {number} options.retryMs - Mean delay between acquisition attempts (jittered). * @param {number} options.staleMs - Age threshold for a lock with no readable owner, floored at OWNERLESS_GRACE_MS. * @param {number} [options.maxWaitMs] - Absolute ceiling on waiting, however much progress the lock makes. Omitted: the policy's default, DEFAULT_MAX_WAIT_FACTOR x timeoutMs. * @param {object} [options.owner] - Extra owner.json fields, recorded for diagnostics. * @param {boolean} [options.createParent=false] - mkdir -p the lock's parent first. * @param {(info: {lockDir: string, expiredMs: number, owner: object, lastMkdirError: string|null, diagnosis: string}) => Error} [options.timeoutError] * Builds the error thrown when the wait gives up. `expiredMs` is the bound that * actually ran out; `diagnosis` is a ready-made " — owner=…" suffix. * @param {() => void} [options.onRecoverGuard] - Called each time this caller takes the recover guard (test ordering hook). * @param {(lockDir: string) => void} [options.removeLock] - Release's rm, injectable for fault tests. * @returns {Promise<{lockDir: string, token: string, attempts: number, waitMs: number, staleRecovered: boolean, release: () => void}>} */ export async function acquireDirLock(lockDir, options) { const { timeoutMs, retryMs, maxWaitMs } = options; const startedAt = Date.now(); // Deliberately NOT floored at timeoutMs. A caller that asks for a ceiling // below one per-holder window means it, and quietly raising it would make // maxWaitMs a no-op in precisely the range where it was stated most // explicitly. The ceiling wins; timeoutMs just stops mattering. const hardDeadline = maxWaitMs === undefined ? undefined : startedAt + maxWaitMs; const ceilingMs = maxWaitMs ?? timeoutMs * DEFAULT_MAX_WAIT_FACTOR; const { backoffMs, holderStillWedged, noteWaiting, ceilingReached } = createLockWaitPolicy(lockDir, { timeoutMs, retryMs, deadline: startedAt + timeoutMs, hardDeadline, }); // state.lastContentionError is the last mkdir error this caller treated as // contention. On POSIX it is always EEXIST and says nothing; on Windows an // EPERM/EACCES that persists to the deadline is the difference between // "crowded" and "this process cannot create directories here at all", and // without it a real permissions problem would present as a plain, // unexplained timeout. const state = newAttemptState(noteWaiting); // Built at the throw site so the diagnosis reflects the moment it gave up. // A timeout on a critical section that is a single sub-millisecond append // has more than one explanation, and the owner record separates them: // - no owner, or an owner whose PID is dead: real contention, and the // structural answer is a fair queue rather than a bigger budget. // - an owner reported ALIVE after tens of seconds: the directory outlived // its holder, because release()'s rm can fail on Windows while a handle // is open and is swallowed by design. // Diagnostic only: it never changes whether the error is thrown. // `expiredMs` is the bound that actually ran out, so the message names the // limit that was applied. Reporting timeoutMs when the CEILING expired states // a number that was never in force. const buildTimeoutError = (expiredMs = timeoutMs) => { const info = { lockDir, expiredMs, owner: null, lastMkdirError: null, diagnosis: '' }; try { const { inspected, owner } = readLockOwner(lockDir); info.owner = owner ? { pid: owner.pid, alive: processIsAlive(owner.pid), started_at: owner.started_at, heldMs: Date.parse(owner.started_at) ? Date.now() - Date.parse(owner.started_at) : null } // The note separates the two cases the diagnosis could not otherwise // tell apart, because they point at different bugs: a stamp that is // absent is an acquisition still in flight, while one that could not be // read is the condition lockRecoveryVerdict refuses to condemn. : { pid: null, alive: null, note: !inspected ? 'owner.json exists but could not be read' : existsSync(lockDir) ? 'lock exists with no owner.json' : 'lock vanished before it could be read', }; info.diagnosis = ` — owner=${JSON.stringify(info.owner)}`; const lastContentionError = state.lastContentionError; if (lastContentionError && lastContentionError.code !== 'EEXIST') { info.lastMkdirError = lastContentionError.code; info.diagnosis += `, last mkdir error=${lastContentionError.code} on ${lastContentionError.path ?? '?'}`; } } catch { /* diagnosis must never mask the timeout it describes */ } return options.timeoutError ? options.timeoutError(info) : new DirLockTimeoutError(lockDir, expiredMs); }; // A fresh install may not have data/ yet — plugins.mjs's cmdRun calls // appendToPipeline with no directory pre-creation, and appendPortalHealth() // creates data/ only after its lock is taken — so create it here rather // than letting mkdirSync(lockDir) throw a raw ENOENT. if (options.createParent) mkdirSync(dirname(lockDir), { recursive: true }); for (;;) { // The ceiling is tested FIRST, on its own, on every pass. Nesting it inside // the per-holder deadline made it conditional on a branch that may never // run: a caller whose maxWaitMs is below timeoutMs never reaches the inner // check, a re-arm pushes the next look a whole window away, and the reclaim // fast-path continues straight past both. A bound that holds only when // another bound happens to fire is not a bound. if (ceilingReached()) throw buildTimeoutError(ceilingMs); const outcome = attemptDirLock(lockDir, state, options); if (outcome === PASS_STAMPED) { if (await settleAsync(confirmStamp(lockDir, state))) return lockHandle(lockDir, state, startedAt, options); // Not ours any more: a lost race, judged like the others below. } else if (outcome === PASS_RECLAIMED) { continue; } if (holderStillWedged()) throw buildTimeoutError(); if (outcome !== PASS_BUSY) continue; // Jitter, because a FIXED retry makes every waiter wake at the same // instant and re-race, and whoever loses is picked at random rather than // queued. With N waiters that is the coupon-collector problem: serving // all of them takes about N·H(N) rounds, so 30 concurrent adds need ~120 // and the default budget only affords 8000/80 = 100. The starving writer // then times out and its item is LOST — the exact failure #2777 removed // from the silent path, reappearing as a loud one under contention. // Spreading wake-ups over [0.5x, 1.5x) breaks the herd so waiters stop // colliding on every round. It does not make the lock fair; it makes // unfairness cost a retry instead of an item. // // Measured, 20 runs per arm alternated on the same machine with the // budget forced to 220ms (2.75 rounds for 30 writers, short by // construction so the effect is visible without waiting out 8s): // with jitter 2 of 20 runs lose an item // without jitter 12 of 20 // So this is a ~6x reduction, NOT a cure: a starving writer is still // possible, and the structural fix is a fair queue rather than a retry // lottery. Left as a lottery because fairness needs an ordered wait and // that is a different lock; recorded here so nobody reads the jitter as // "the race is gone". await sleep(backoffMs()); } } /** * Takes the directory lock at `lockDir` if it can be had now, without waiting: * returns the same handle acquireDirLock does, or null when another holder has * it. For a caller whose answer to contention is "busy, retry" rather than a * queue — a synchronous request handler returning 409, say. * * Not a weaker lock. It is the same pass acquireDirLock makes, so a stale lock * (a crashed holder, an ownerless leftover past its age-out) is reclaimed in * line and the caller proceeds; only a LIVE holder answers null. The one wait * it shares is confirmStamp's: an unreadable read-back of its own stamp * blocks for at most READBACK_RETRIES x READBACK_RETRY_MS (50ms) before the * directory's identity decides. A readable stamp, the normal case, never waits. * * @param {string} lockDir - The lock directory itself (not the file it guards). * @param {object} options * @param {number} options.staleMs - Age threshold for a lock with no readable owner, floored at OWNERLESS_GRACE_MS. * @param {object} [options.owner] - Extra owner.json fields, recorded for diagnostics. * @param {boolean} [options.createParent=false] - mkdir -p the lock's parent first. * @param {() => void} [options.onRecoverGuard] - Called each time this caller takes the recover guard (test ordering hook). * @param {(lockDir: string) => void} [options.removeLock] - Release's rm, injectable for fault tests. * @returns {{lockDir: string, token: string, attempts: number, waitMs: number, staleRecovered: boolean, release: () => void} | null} */ export function tryAcquireDirLockSync(lockDir, options) { const startedAt = Date.now(); const state = newAttemptState(); if (options.createParent) mkdirSync(dirname(lockDir), { recursive: true }); for (let pass = 0; pass < SYNC_MAX_PASSES; pass++) { const outcome = attemptDirLock(lockDir, state, options); if (outcome === PASS_STAMPED && settleSync(confirmStamp(lockDir, state))) { return lockHandle(lockDir, state, startedAt, options); } if (outcome === PASS_BUSY) return null; } return null; } /** * The handle's release(). Idempotent, and retryable: a release that throws can * be called again, and it remembers what it had already verified. * * It removes the lock only when it can show the directory is still the one this * caller stamped: a stat on either side of reading the stamp, compared by * identity, so a lock that was reclaimed and recreated in between is left to * its new owner. Our operation can outlive staleMs, and another process can * then legitimately reclaim the lock — deleting it here would free someone * else's critical section. * * Throws when ownership cannot be established either way (the directory is * there but its stamp is missing or unreadable) and on a removal failure that * is not Windows contention. Contention is swallowed: ownership was verified, * so a contended rm (EPERM/EBUSY while another process stats the directory) * must not fail a caller whose work already succeeded; the orphan ages out via * lockRecoveryVerdict. */ function createRelease(lockDir, token, removeLock = (path) => rmSync(path, { recursive: true, force: true })) { let released = false; let verifiedDir = null; // Released, whether or not the rm below succeeded: the handle no longer // holds the lock, so its token stops counting as live (see activeTokens). const finish = () => { released = true; activeTokens.delete(token); }; return function release() { if (released) return; if (verifiedDir) { // A previous call verified ownership and then failed to remove. Its own // partial cleanup may have taken owner.json with it, so a missing stamp // is no longer evidence against us; the directory's identity is. let currentDir; try { currentDir = statSync(lockDir); } catch (err) { if (err?.code === 'ENOENT') { finish(); return; } throw err; } if (!sameLockDirectory(verifiedDir, currentDir)) { finish(); return; } const { owner } = readLockOwner(lockDir); if (owner && owner.token !== token) { finish(); return; } if (!owner && existsSync(join(lockDir, 'owner.json'))) { throw new Error(`Cannot verify lock ownership at ${lockDir}`); } } else { let beforeRead; try { beforeRead = statSync(lockDir); } catch (err) { if (err?.code === 'ENOENT') { finish(); return; } throw err; } const { owner } = readLockOwner(lockDir); if (owner?.token !== token) { if (owner) { finish(); // reclaimed by someone else — leave it alone return; } throw new Error(`Cannot verify lock ownership at ${lockDir}`); } const afterRead = statSync(lockDir); if (!sameLockDirectory(beforeRead, afterRead)) { finish(); // swapped underneath us return; } verifiedDir = afterRead; } try { removeLock(lockDir); } catch (rmErr) { if (!isRmContention(rmErr)) throw rmErr; } finish(); }; } /** * Release a handle from a `finally`, where a throw would replace the caller's * own outcome: a failed release must neither mask the error that is unwinding * nor turn a completed write into a reported failure. A warning is the honest * answer, and the handle is then abandoned: nothing will retry this release. * * So its token stops counting as live here, even though release() itself * keeps it (a handle whose release threw may be retried, and until then it * still holds the lock). An abandoned token left in activeTokens would pin the * leftover LIVE until this process exits — a chat answering 409, a store * timing out, until the web server restarts. Dropped, the leftover goes to the * age rule like any other stamp of ours with no handle behind it. */ export function releaseOrWarn(lock) { try { lock.release(); } catch (err) { activeTokens.delete(lock.token); console.error(`Warning: lock cleanup failed at ${lock.lockDir}: ${err.message}`); } }