* fix(#1217): bound acquireStateLock retries on recoverable errno (no busy-spin) The recoverable-errno branch (`ACQUIRE_LOCK_RETRY_ERRNOS`) previously called `continue` directly, skipping both `clock.sleep()` and the 30 000 ms budget check. A permanently-failing ENOENT (e.g. parent dir removed) would spin at 100% CPU forever with the event loop fully blocked, making the OS-level `timeout` the only escape. Fix: extract a `checkBudgetAndSleep(context)` helper and call it from BOTH the recoverable-errno path and the EEXIST contention path so every retry is bounded and backed off identically. Regression tests added to tests/clock-seam.test.cjs: - persistent ENOENT throws budget-exceeded error (clock must advance via sleep) - transient ENOENT (2 retries then success) acquires lock normally Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#1217): backfill changeset PR number (#1236) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1440,6 +1440,22 @@ function acquireStateLock(statePath: string, clock?: StateLockClock): string {
|
||||
const maxWaitMs = 30000;
|
||||
const startedAt = clock.now();
|
||||
|
||||
// Shared helper: check the time budget then back off with jitter before the
|
||||
// next retry. Both the EEXIST contention path and the recoverable-errno path
|
||||
// must go through this so neither can busy-spin (#1217).
|
||||
const checkBudgetAndSleep = (context: string) => {
|
||||
if (clock.now() - startedAt >= maxWaitMs) {
|
||||
const e = new Error(
|
||||
'acquireStateLock: ' + lockPath + ' ' + context + ' for ' +
|
||||
(clock.now() - startedAt) + 'ms (exceeded ' + maxWaitMs + 'ms budget)'
|
||||
);
|
||||
(e as unknown as Record<string, unknown>).lockBudgetExceeded = true;
|
||||
throw e;
|
||||
}
|
||||
const jitter = Math.floor(Math.random() * 50);
|
||||
clock.sleep(retryDelay + jitter);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
||||
@@ -1450,9 +1466,13 @@ function acquireStateLock(statePath: string, clock?: StateLockClock): string {
|
||||
return lockPath;
|
||||
} catch (err) {
|
||||
// Transient filesystem errors (Docker overlay-fs, NFS, OS signals, AV scanners)
|
||||
// are recoverable — retry the acquisition loop rather than propagating.
|
||||
// are recoverable — retry with the same budget + backoff as the EEXIST path so
|
||||
// a permanently-failing errno cannot busy-spin at 100% CPU (#1217).
|
||||
// See ACQUIRE_LOCK_RETRY_ERRNOS for the full list and rationale.
|
||||
if (ACQUIRE_LOCK_RETRY_ERRNOS.has((err as NodeJS.ErrnoException).code as string)) { continue; }
|
||||
if (ACQUIRE_LOCK_RETRY_ERRNOS.has((err as NodeJS.ErrnoException).code as string)) {
|
||||
checkBudgetAndSleep((err as NodeJS.ErrnoException).code + ' persisted');
|
||||
continue;
|
||||
}
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; // propagate — silent bypass causes lost updates
|
||||
// Only unlink a lock we did not place when it has crossed the staleness
|
||||
// threshold (crashed holder). Nuking a fresh lock held by a slow-but-live
|
||||
@@ -1460,18 +1480,33 @@ function acquireStateLock(statePath: string, clock?: StateLockClock): string {
|
||||
try {
|
||||
const stat = fs.statSync(lockPath);
|
||||
if ((clock).now() - stat.mtimeMs > staleThresholdMs) {
|
||||
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
|
||||
let removed = false;
|
||||
try { fs.unlinkSync(lockPath); removed = true; } catch { /* swallow: bounded below */ }
|
||||
if (removed) {
|
||||
// Successful steal — retry immediately to grab the just-freed lock.
|
||||
// Must NOT call checkBudgetAndSleep here: a throw-after-delete would
|
||||
// corrupt the filesystem state, and the budget is already bounded on
|
||||
// the next iteration's EEXIST or open attempt (#1217 regression fix).
|
||||
continue;
|
||||
}
|
||||
// Persistent unlinkSync failure — apply budget + backoff so it cannot
|
||||
// busy-spin (#1217).
|
||||
checkBudgetAndSleep('stale lock removal failed');
|
||||
continue;
|
||||
}
|
||||
} catch { continue; /* released between EEXIST and stat */ }
|
||||
if ((clock).now() - startedAt >= maxWaitMs) {
|
||||
throw new Error(
|
||||
'acquireStateLock: ' + lockPath + ' held by live process for ' +
|
||||
((clock).now() - startedAt) + 'ms (exceeded ' + maxWaitMs + 'ms budget)'
|
||||
);
|
||||
} catch (err) {
|
||||
// Re-throw a budget-exceeded error from the unlinkSync failure path above
|
||||
// unchanged — its message already names the real cause ("stale lock removal
|
||||
// failed") and double-wrapping it would replace that with the misleading
|
||||
// "statSync failed after EEXIST" context string (#1217 diagnostic fix).
|
||||
if ((err as Record<string, unknown>)?.lockBudgetExceeded) throw err;
|
||||
// statSync failed — lock was likely released between our EEXIST and this
|
||||
// stat call. Apply budget + backoff so a persistent statSync failure
|
||||
// cannot busy-spin (#1217).
|
||||
checkBudgetAndSleep('statSync failed after EEXIST');
|
||||
continue;
|
||||
}
|
||||
const jitter = Math.floor(Math.random() * 50);
|
||||
(clock).sleep(retryDelay + jitter);
|
||||
checkBudgetAndSleep('held by live process');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user