Files
ECC/scripts/lib/state-store/file-lock.js
T
affaan-m ed4b856bfe fix(state-store): preserve ownership and errors through cleanup
Preserve the original contributor histories and apply the exact independently reviewed repair.

Source-Parent: 5785183108
Review-Manifest-SHA256: e223d98c24e357715527b786257901d2ef0bebadb75ac61e5c000fe60da10f9a
2026-09-28 07:29:56 -04:00

131 lines
4.7 KiB
JavaScript

'use strict';
const fs = require('fs');
const os = require('os');
const { performance } = require('perf_hooks');
const WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
const DEFAULT_TIMEOUT_MS = 5000;
// Cleanup must never replace an arbitrary primary throw (including frozen errors,
// primitives, or objects with throwing property traps). Diagnostics are best effort.
function attachCleanupError(primary, name, secondary) {
if (primary === null || (typeof primary !== 'object' && typeof primary !== 'function')) return;
try { Object.defineProperty(primary, name, { value: secondary, configurable: true }); }
catch (_error) { /* Preserve the primary when it cannot accept diagnostics. */ }
}
function hasCode(error, code) {
try { return error !== null && error !== undefined && error.code === code; }
catch (_error) { return false; }
}
function sameIdentity(left, right) {
return left.ino === right.ino && left.dev === right.dev;
}
function lostLock(lockPath) {
return Object.assign(new Error(`State-store lock changed or lost; refusing to remove it: ${lockPath}`),
{ code: 'STATE_STORE_LOCK_LOST' });
}
function releaseOwnedLock(lockPath, descriptor, identity) {
let failed = false;
let primary;
let pendingDelete = false;
let inspected = false;
let closeFailed = false;
try {
const current = fs.lstatSync(lockPath, { bigint: true });
inspected = true;
if (!current.isFile() || current.isSymbolicLink() || !sameIdentity(identity, current)) {
throw lostLock(lockPath);
}
// Keep the owned descriptor through validation and unlink. This cooperative
// lock is not an atomic compare-and-unlink against a directory mutator.
fs.unlinkSync(lockPath);
} catch (error) {
failed = true;
primary = hasCode(error, 'ENOENT') ? lostLock(lockPath) : error;
pendingDelete = !inspected && hasCode(error, 'EPERM');
}
try {
fs.closeSync(descriptor); // Exactly one attempt, even after a release failure.
} catch (error) {
closeFailed = true;
if (failed) attachCleanupError(primary, 'closeError', error);
else { failed = true; primary = error; }
}
if (pendingDelete && !closeFailed) {
// Windows can defer deletion until the handle closes. Only a subsequent
// ENOENT proves loss; preserve genuine EPERM and never unlink after close.
try { fs.lstatSync(lockPath, { bigint: true }); }
catch (error) { if (hasCode(error, 'ENOENT')) primary = lostLock(lockPath); }
}
if (failed) throw primary;
}
function acquireLock(dbPath, timeoutMs) {
const lockPath = `${dbPath}.ecc-state.lock`;
const deadline = performance.now() + timeoutMs;
for (;;) {
let descriptor;
try {
descriptor = fs.openSync(lockPath, 'wx', 0o600);
} catch (error) {
if (!hasCode(error, 'EEXIST')) throw error;
const remaining = deadline - performance.now();
if (remaining <= 0) {
const busy = new Error(`State store is busy: ${dbPath}. Retry after the other ECC operation finishes. `
+ `If an operation terminated unexpectedly, stop all ECC processes using this database, `
+ `then inspect and remove the leftover lock: ${lockPath}`);
busy.code = 'STATE_STORE_BUSY';
throw busy;
}
Atomics.wait(WAIT_BUFFER, 0, 0, Math.min(20, remaining));
continue;
}
let identity;
try {
identity = fs.fstatSync(descriptor, { bigint: true });
fs.writeFileSync(descriptor, `${JSON.stringify({ pid: process.pid, hostname: os.hostname() })}\n`);
} catch (error) {
try {
if (identity) releaseOwnedLock(lockPath, descriptor, identity);
else fs.closeSync(descriptor);
} catch (releaseError) {
attachCleanupError(error, 'releaseError', releaseError);
}
throw error;
}
return () => releaseOwnedLock(lockPath, descriptor, identity);
}
}
/** Serialize one synchronous snapshot operation, never a handle's lifetime.
* Locks are not expired or stolen: a paused writer must not lose exclusivity.
* An abnormal process exit can leave a lock requiring the explicit recovery
* described by STATE_STORE_BUSY. No database bytes are changed on timeout. */
function withStateStoreLock(dbPath, callback, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
const release = acquireLock(dbPath, timeoutMs);
let result;
let primaryError;
let failed = false;
try {
result = callback();
} catch (error) {
failed = true;
primaryError = error;
}
try {
release();
} catch (releaseError) {
if (!failed) throw releaseError;
attachCleanupError(primaryError, 'releaseError', releaseError);
}
if (failed) throw primaryError;
return result;
}
module.exports = { withStateStoreLock, attachCleanupError };