Foundation lib for v1.12.0 cron rewrite. Atomic exclusive create via
fs.writeFileSync('wx'); on EEXIST resolves staleness with OR semantics:
stale if PID is dead OR mtime exceeds threshold. Either alone breaks the
lock — handles SIGKILL orphans (mtime), PID-reuse races (mtime), and
crashed-then-replaced runs (PID).
- acquireLock(lockPath, opts) → {lockPath, release()}
- staleThresholdMs default 1h; refreshIntervalMs opt-in for long runs
- registerCleanup default true (exit/SIGINT/SIGTERM/SIGHUP/uncaughtException)
- isPidAlive uses kill(pid, 0) with EPERM-as-alive nuance
12/12 tests pass: PID liveness, fixture concurrency, idempotent release,
stale variants (dead+old, live+old, fresh+live), staleThresholdMs honored.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
192 lines
5.6 KiB
JavaScript
192 lines
5.6 KiB
JavaScript
// tests/kb-update/test-lock-file.test.mjs
|
|
// Unit tests for scripts/kb-update/lib/lock-file.mjs
|
|
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
mkdtempSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
readFileSync,
|
|
existsSync,
|
|
utimesSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
acquireLock,
|
|
isPidAlive,
|
|
} from '../../scripts/kb-update/lib/lock-file.mjs';
|
|
|
|
const DEAD_PID = 99999999; // far above typical PID_MAX; reliably non-existent
|
|
|
|
function withTmp(fn) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'lf-test-'));
|
|
try {
|
|
return fn(dir);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function writeFakeLock(path, { pid, started, host = 'test-host', ageMs = 0 }) {
|
|
writeFileSync(
|
|
path,
|
|
JSON.stringify({
|
|
pid,
|
|
started: started ?? Date.now() - ageMs,
|
|
host,
|
|
version: 1,
|
|
}),
|
|
'utf8'
|
|
);
|
|
if (ageMs > 0) {
|
|
const past = new Date(Date.now() - ageMs);
|
|
utimesSync(path, past, past);
|
|
}
|
|
}
|
|
|
|
test('isPidAlive — current process is alive', () => {
|
|
assert.equal(isPidAlive(process.pid), true);
|
|
});
|
|
|
|
test('isPidAlive — non-existent PID is dead', () => {
|
|
assert.equal(isPidAlive(DEAD_PID), false);
|
|
});
|
|
|
|
test('isPidAlive — invalid input is dead', () => {
|
|
assert.equal(isPidAlive(0), false);
|
|
assert.equal(isPidAlive(-1), false);
|
|
assert.equal(isPidAlive(NaN), false);
|
|
assert.equal(isPidAlive(undefined), false);
|
|
});
|
|
|
|
test('acquireLock — creates lock file with current PID metadata', () => {
|
|
withTmp((dir) => {
|
|
const path = join(dir, 'test.lock');
|
|
const lock = acquireLock(path, { registerCleanup: false });
|
|
try {
|
|
assert.equal(lock.lockPath, path);
|
|
assert.equal(existsSync(path), true);
|
|
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
assert.equal(data.pid, process.pid);
|
|
assert.equal(data.version, 1);
|
|
assert.equal(typeof data.started, 'number');
|
|
assert.equal(typeof data.host, 'string');
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
});
|
|
});
|
|
|
|
test('acquireLock — second call same process throws ELOCKED', () => {
|
|
withTmp((dir) => {
|
|
const path = join(dir, 'test.lock');
|
|
const lock = acquireLock(path, { registerCleanup: false });
|
|
try {
|
|
assert.throws(
|
|
() => acquireLock(path, { registerCleanup: false }),
|
|
(err) => err.code === 'ELOCKED' && err.holderPid === process.pid
|
|
);
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
});
|
|
});
|
|
|
|
test('acquireLock — concurrent live holder (fixture lock-fil) throws ELOCKED', () => {
|
|
withTmp((dir) => {
|
|
const path = join(dir, 'test.lock');
|
|
// Pre-write a lock as if held by another live process (we use process.pid
|
|
// as a stand-in for "guaranteed alive" without forking).
|
|
writeFakeLock(path, { pid: process.pid, ageMs: 0 });
|
|
assert.throws(
|
|
() => acquireLock(path, { registerCleanup: false }),
|
|
(err) => err.code === 'ELOCKED'
|
|
);
|
|
});
|
|
});
|
|
|
|
test('acquireLock — release deletes the lock file', () => {
|
|
withTmp((dir) => {
|
|
const path = join(dir, 'test.lock');
|
|
const lock = acquireLock(path, { registerCleanup: false });
|
|
assert.equal(existsSync(path), true);
|
|
lock.release();
|
|
assert.equal(existsSync(path), false);
|
|
});
|
|
});
|
|
|
|
test('acquireLock — release on already-released lock is a no-op', () => {
|
|
withTmp((dir) => {
|
|
const path = join(dir, 'test.lock');
|
|
const lock = acquireLock(path, { registerCleanup: false });
|
|
lock.release();
|
|
// Second release must not throw.
|
|
lock.release();
|
|
assert.equal(existsSync(path), false);
|
|
});
|
|
});
|
|
|
|
test('acquireLock — stale lock with dead PID + old mtime is cleaned', () => {
|
|
withTmp((dir) => {
|
|
const path = join(dir, 'test.lock');
|
|
writeFakeLock(path, { pid: DEAD_PID, ageMs: 2 * 60 * 60 * 1000 });
|
|
const lock = acquireLock(path, { registerCleanup: false });
|
|
try {
|
|
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
assert.equal(data.pid, process.pid);
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
});
|
|
});
|
|
|
|
test('acquireLock — stale lock with live PID but old mtime is also cleaned', () => {
|
|
withTmp((dir) => {
|
|
const path = join(dir, 'test.lock');
|
|
// Live PID (us) but mtime older than default 1h threshold.
|
|
writeFakeLock(path, { pid: process.pid, ageMs: 2 * 60 * 60 * 1000 });
|
|
const lock = acquireLock(path, { registerCleanup: false });
|
|
try {
|
|
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
assert.equal(data.pid, process.pid);
|
|
// started is rewritten to fresh wallclock
|
|
assert.ok(Date.now() - data.started < 5000);
|
|
} finally {
|
|
lock.release();
|
|
}
|
|
});
|
|
});
|
|
|
|
test('acquireLock — fresh lock with live PID is NOT cleaned', () => {
|
|
withTmp((dir) => {
|
|
const path = join(dir, 'test.lock');
|
|
writeFakeLock(path, { pid: process.pid, ageMs: 0 });
|
|
assert.throws(
|
|
() => acquireLock(path, { registerCleanup: false }),
|
|
(err) => err.code === 'ELOCKED' && err.holderPid === process.pid
|
|
);
|
|
});
|
|
});
|
|
|
|
test('acquireLock — staleThresholdMs is honored', () => {
|
|
withTmp((dir) => {
|
|
const path = join(dir, 'test.lock');
|
|
// 5s-old, live PID. Default 1h threshold → not stale → ELOCKED.
|
|
writeFakeLock(path, { pid: process.pid, ageMs: 5_000 });
|
|
assert.throws(
|
|
() => acquireLock(path, { registerCleanup: false }),
|
|
(err) => err.code === 'ELOCKED'
|
|
);
|
|
|
|
// Same fixture but threshold 1s → stale → cleaned.
|
|
writeFakeLock(path, { pid: process.pid, ageMs: 5_000 });
|
|
const lock = acquireLock(path, {
|
|
registerCleanup: false,
|
|
staleThresholdMs: 1_000,
|
|
});
|
|
lock.release();
|
|
assert.equal(existsSync(path), false);
|
|
});
|
|
});
|