GitNexus/gitnexus/test/unit/index-lock.test.ts

381 lines
16 KiB
TypeScript

/**
* Unit tests for the cross-process index write lock (#2658).
*
* These exercise the lock's decision logic deterministically by pre-seeding
* `analyze.lock` records and asserting acquire/steal/release/sweep behavior —
* including the kill-recovery mechanism (a dead holder's lock is reclaimed) and
* mutual exclusion (a live holder is waited on, never stolen). A real
* two-process exclusion + SIGKILL-recovery test lives in
* test/integration/analyze-index-lock-concurrency.test.ts.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
mkdtempSync,
rmSync,
writeFileSync,
readFileSync,
existsSync,
chmodSync,
symlinkSync,
} from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
acquireIndexLock,
sweepStagingArtifacts,
isLockUnwritableCode,
IndexLockTimeoutError,
type LockRecord,
} from '../../src/storage/index-lock.js';
import { classifyFtsBuildError, ftsFailureIsFatal } from '../../src/core/search/fts-indexes.js';
let dir: string;
const lockPath = () => path.join(dir, 'analyze.lock');
const seedLock = (overrides: Partial<LockRecord>): void => {
const record: LockRecord = {
v: 1,
pid: 999999999, // implausible pid → dead by default
hostname: os.hostname(),
startTime: null,
token: 'seed-token',
invocationId: 'seed-invocation',
acquiredAt: new Date().toISOString(),
...overrides,
};
writeFileSync(lockPath(), JSON.stringify(record));
};
beforeEach(() => {
dir = mkdtempSync(path.join(os.tmpdir(), 'gnx-lock-'));
// These suites exercise the file (O_EXCL pidfile) backend directly. On Linux
// the default is the socket backend, so pin the file backend explicitly.
process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'file';
});
afterEach(() => {
delete process.env.GITNEXUS_INDEX_LOCK_BACKEND;
rmSync(dir, { recursive: true, force: true });
});
describe('acquireIndexLock', () => {
it('acquires a free directory and writes a record carrying our pid', async () => {
const lock = await acquireIndexLock(dir);
expect(existsSync(lockPath())).toBe(true);
const onDisk = JSON.parse(readFileSync(lockPath(), 'utf8')) as LockRecord;
expect(onDisk).toMatchObject({ v: 1, pid: process.pid, hostname: os.hostname() });
expect(lock.record.token).toBe(onDisk.token);
lock.release();
expect(existsSync(lockPath())).toBe(false);
});
it('reclaims a stale lock left by a dead process (kill recovery)', async () => {
seedLock({ pid: 999999999, token: 'dead-holder' });
const lock = await acquireIndexLock(dir, { timeoutMs: 2000 });
const onDisk = JSON.parse(readFileSync(lockPath(), 'utf8')) as LockRecord;
expect(onDisk.pid).toBe(process.pid);
expect(onDisk.token).not.toBe('dead-holder');
lock.release();
});
it('waits on a live holder and times out instead of stealing (mutual exclusion)', async () => {
// A live pid (our own) with a different token — never stale, so acquire
// must block and then time out rather than clobber the holder.
seedLock({ pid: process.pid, startTime: null, token: 'live-holder' });
await expect(acquireIndexLock(dir, { timeoutMs: 300, pollMs: 20 })).rejects.toBeInstanceOf(
IndexLockTimeoutError,
);
// The live holder's record is untouched.
const onDisk = JSON.parse(readFileSync(lockPath(), 'utf8')) as LockRecord;
expect(onDisk.token).toBe('live-holder');
});
it('surfaces the holder identity on timeout', async () => {
seedLock({ pid: process.pid, startTime: null, token: 'live-holder', invocationId: 'held-run' });
await expect(acquireIndexLock(dir, { timeoutMs: 200, pollMs: 20 })).rejects.toMatchObject({
holder: { invocationId: 'held-run', pid: process.pid },
});
});
it.skipIf(process.platform !== 'linux')(
'treats a reused pid (live pid, different start time) as stale',
async () => {
// Our pid is alive but the seeded start time cannot match it → reused.
seedLock({ pid: process.pid, startTime: '1', token: 'reused-pid' });
const lock = await acquireIndexLock(dir, { timeoutMs: 2000 });
const onDisk = JSON.parse(readFileSync(lockPath(), 'utf8')) as LockRecord;
expect(onDisk.token).not.toBe('reused-pid');
lock.release();
},
);
it('reclaims an empty lock file (crash between O_EXCL create and record write) without hanging', async () => {
// Pre-fix, readRecord→null hot-looped forever here. Post-fix it reclaims
// the malformed orphan after the grace and acquires.
writeFileSync(lockPath(), '');
const lock = await acquireIndexLock(dir, { timeoutMs: 5000, pollMs: 20 });
const onDisk = JSON.parse(readFileSync(lockPath(), 'utf8')) as LockRecord;
expect(onDisk.pid).toBe(process.pid);
lock.release();
});
it('reclaims a partial/malformed record (valid JSON, missing token) without hanging', async () => {
writeFileSync(lockPath(), '{"pid":123}');
const lock = await acquireIndexLock(dir, { timeoutMs: 5000, pollMs: 20 });
const onDisk = JSON.parse(readFileSync(lockPath(), 'utf8')) as LockRecord;
expect(onDisk.pid).toBe(process.pid);
expect(onDisk.token.length).toBeGreaterThan(0);
lock.release();
});
it('treats a non-positive/NaN pid as no readable holder and reclaims (never wedges on process.kill) (#2658 review L4)', async () => {
// `{"pid":0}` pre-fix: typeof 0 === 'number' passed readRecord, then
// process.kill(0,0) reported the process group "alive" → treated as a live
// holder → the acquire wedged until the full timeout. Post-fix a pid that is
// not a positive integer makes readRecord return null, so the file is a
// malformed orphan that is reclaimed after the grace.
seedLock({ pid: 0, token: 'zero-pid' });
const lock = await acquireIndexLock(dir, { timeoutMs: 5000, pollMs: 20 });
const onDisk = JSON.parse(readFileSync(lockPath(), 'utf8')) as LockRecord;
expect(onDisk.pid).toBe(process.pid);
expect(onDisk.token).not.toBe('zero-pid');
lock.release();
});
it('honors GITNEXUS_INDEX_LOCK_TIMEOUT_MS as the wait ceiling (bounds pid-reuse hangs)', async () => {
// A live holder we cannot steal (own pid, no start-time recorded). Without a
// finite ceiling this would hang; the env var must bound it (#2658).
seedLock({ pid: process.pid, startTime: null, token: 'live-holder' });
const prev = process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS;
process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS = '150';
try {
// No explicit timeoutMs → the env ceiling applies (not the 10-min default).
await expect(acquireIndexLock(dir, { pollMs: 20 })).rejects.toBeInstanceOf(
IndexLockTimeoutError,
);
} finally {
if (prev === undefined) delete process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS;
else process.env.GITNEXUS_INDEX_LOCK_TIMEOUT_MS = prev;
}
});
});
describe('release', () => {
it('does not remove a lock that has been re-taken by another owner', async () => {
const lock = await acquireIndexLock(dir);
// Simulate the file being replaced by a different owner after we acquired.
seedLock({ pid: process.pid, token: 'someone-else' });
lock.release();
expect(existsSync(lockPath())).toBe(true); // not ours → left intact
const onDisk = JSON.parse(readFileSync(lockPath(), 'utf8')) as LockRecord;
expect(onDisk.token).toBe('someone-else');
});
it('is idempotent', async () => {
const lock = await acquireIndexLock(dir);
lock.release();
expect(() => lock.release()).not.toThrow();
});
});
describe('sweepStagingArtifacts', () => {
it('removes only staging files, never the live index or its sidecars', () => {
const files = [
'lbug',
'lbug.wal',
'lbug.shadow',
'lbug.new',
'lbug.new.wal',
'lbug.new.wal.checkpoint',
'lbug.staging.abc-123',
'lbug.staging.abc-123.wal',
'lbug.staging.abc-123.shadow',
'gitnexus.json',
];
for (const f of files) writeFileSync(path.join(dir, f), 'x');
sweepStagingArtifacts(dir);
const survives = (f: string) => existsSync(path.join(dir, f));
expect(survives('lbug')).toBe(true);
expect(survives('lbug.wal')).toBe(true);
expect(survives('lbug.shadow')).toBe(true);
expect(survives('gitnexus.json')).toBe(true);
expect(survives('lbug.new')).toBe(false);
expect(survives('lbug.new.wal')).toBe(false);
expect(survives('lbug.new.wal.checkpoint')).toBe(false);
expect(survives('lbug.staging.abc-123')).toBe(false);
expect(survives('lbug.staging.abc-123.wal')).toBe(false);
expect(survives('lbug.staging.abc-123.shadow')).toBe(false);
});
it('runs the sweep automatically on acquire', async () => {
writeFileSync(path.join(dir, 'lbug.staging.orphan'), 'x');
writeFileSync(path.join(dir, 'lbug'), 'x');
const lock = await acquireIndexLock(dir);
expect(existsSync(path.join(dir, 'lbug.staging.orphan'))).toBe(false);
expect(existsSync(path.join(dir, 'lbug'))).toBe(true);
lock.release();
});
});
describe('classifyFtsBuildError', () => {
it('classifies IO/rename/checkpoint/corruption failures as integrity', () => {
expect(
classifyFtsBuildError(
'IO exception: Error renaming file lbug.new.wal to lbug.new.wal.checkpoint. ErrorMessage: No such file or directory',
),
).toBe('integrity');
expect(classifyFtsBuildError('checkpoint failed')).toBe('integrity');
expect(classifyFtsBuildError('database file is corrupt')).toBe('integrity');
expect(classifyFtsBuildError('write failed: no space left on device (ENOSPC)')).toBe(
'integrity',
);
});
it('classifies row-level tokenizer failures as capability (degrade)', () => {
expect(classifyFtsBuildError('Failed calling LOWER: Invalid UTF-8')).toBe('capability');
expect(classifyFtsBuildError('tokenizer error on row 5')).toBe('capability');
});
it('defaults unknown failures to capability so runs are not newly failed', () => {
expect(classifyFtsBuildError('some unrecognised message')).toBe('capability');
expect(classifyFtsBuildError('missing indexes after build: File.name_fts')).toBe('capability');
});
it('keeps a bare ENOENT / bad-fd as capability so it degrades, not aborts (#2658 review L1)', () => {
// A missing extension asset / closed handle reports a generic OS error; those
// must NOT escalate to an abort on the atomic-swap path. Only a specific
// write/rename/checkpoint failure is integrity.
expect(classifyFtsBuildError('ENOENT: no such file or directory, open fts.ext')).toBe(
'capability',
);
expect(classifyFtsBuildError('read failed: bad file descriptor (EBADF)')).toBe('capability');
// The genuine build-broke rename race is still integrity via 'error renaming'.
expect(
classifyFtsBuildError('Error renaming lbug.new.wal to checkpoint: No such file or directory'),
).toBe('integrity');
});
it('lets a row-level tokenizer error win even if it mentions an integrity word', () => {
// A tokenizer error is a bad row, not a broken build — must still degrade.
expect(classifyFtsBuildError('Invalid UTF-8 during io exception path')).toBe('capability');
});
});
describe('ftsFailureIsFatal (#2658)', () => {
it('is fatal ONLY for an integrity failure on the atomic-swap path', () => {
// Atomic swap: staging DB, previous index intact → integrity may abort.
expect(ftsFailureIsFatal('integrity', true)).toBe(true);
// In-place: live DB already mutated, nothing to roll back → degrade.
expect(ftsFailureIsFatal('integrity', false)).toBe(false);
// Capability never aborts, either path.
expect(ftsFailureIsFatal('capability', true)).toBe(false);
expect(ftsFailureIsFatal('capability', false)).toBe(false);
// Missing class (ok result, or no classification) never aborts.
expect(ftsFailureIsFatal(undefined, true)).toBe(false);
});
});
// The OS socket/pipe backend is only meaningful where `net` gives a clean,
// auto-releasing namespace: Linux abstract sockets and Windows named pipes.
describe.skipIf(process.platform !== 'linux' && process.platform !== 'win32')(
'OS socket lock backend (#2658)',
() => {
// Override the file-backend pin from the outer beforeEach.
beforeEach(() => {
process.env.GITNEXUS_INDEX_LOCK_BACKEND = 'socket';
});
it('holds no filesystem lock file (works on a read-only index dir)', async () => {
const lock = await acquireIndexLock(dir, { timeoutMs: 2000 });
expect(existsSync(lockPath())).toBe(false); // endpoint is outside the dir
lock.release();
});
it('excludes a second acquire on the same dir, then frees it on release', async () => {
const first = await acquireIndexLock(dir, { timeoutMs: 2000 });
// A second acquire on the SAME slot is refused by the kernel (EADDRINUSE)
// and waits, then times out — the live holder is never displaced.
await expect(acquireIndexLock(dir, { timeoutMs: 300, pollMs: 20 })).rejects.toBeInstanceOf(
IndexLockTimeoutError,
);
first.release();
// Once released, the endpoint is free again.
const second = await acquireIndexLock(dir, { timeoutMs: 2000 });
second.release();
});
it('reports the holder as unknown on timeout — never a bogus "pid -1" (#2658 review M3)', async () => {
// The OS socket lock exposes no owner metadata, so a contended-wait timeout
// must not surface the unknownHolder() placeholder pid (-1) as if it were a
// real process the operator can look up.
const first = await acquireIndexLock(dir, { timeoutMs: 2000 });
try {
const err = await acquireIndexLock(dir, { timeoutMs: 200, pollMs: 20 }).catch((e) => e);
expect(err).toBeInstanceOf(IndexLockTimeoutError);
expect((err as IndexLockTimeoutError).holderKnown).toBe(false);
expect((err as IndexLockTimeoutError).message).not.toContain('pid -1');
} finally {
first.release();
}
});
it('excludes an acquire reaching the same physical dir via a symlink alias (#2658 review H1)', async () => {
// Pre-fix the endpoint name hashed the LEXICAL path, so `alias` (a symlink
// to `dir`) produced a different name and BOTH acquired — a double-writer.
// Post-fix both canonicalize to `dir`'s real path → one name → excluded.
const alias = mkdtempSync(path.join(os.tmpdir(), 'gnx-lock-aliasparent-'));
const aliasLink = path.join(alias, 'link');
symlinkSync(dir, aliasLink);
try {
const first = await acquireIndexLock(dir, { timeoutMs: 2000 });
await expect(
acquireIndexLock(aliasLink, { timeoutMs: 300, pollMs: 20 }),
).rejects.toBeInstanceOf(IndexLockTimeoutError);
first.release();
} finally {
rmSync(alias, { recursive: true, force: true });
}
});
it('gives independent locks to different index dirs', async () => {
const other = mkdtempSync(path.join(os.tmpdir(), 'gnx-lock-other-'));
try {
const a = await acquireIndexLock(dir, { timeoutMs: 2000 });
const b = await acquireIndexLock(other, { timeoutMs: 2000 }); // distinct name → no contention
a.release();
b.release();
} finally {
rmSync(other, { recursive: true, force: true });
}
});
},
);
describe('read-only / permission-denied filesystem (#2658)', () => {
it('classifies EROFS/EACCES/EPERM as tolerable, others not', () => {
expect(isLockUnwritableCode('EROFS')).toBe(true);
expect(isLockUnwritableCode('EACCES')).toBe(true);
expect(isLockUnwritableCode('EPERM')).toBe(true);
expect(isLockUnwritableCode('EEXIST')).toBe(false);
expect(isLockUnwritableCode('ENOENT')).toBe(false);
expect(isLockUnwritableCode(undefined)).toBe(false);
});
// Mode bits are bypassed for uid 0, so the denied-create path only reproduces
// as non-root. The predicate test above is the always-on guard.
it.skipIf(!process.getuid || process.getuid() === 0)(
'returns a no-op handle instead of throwing when the lock dir cannot be written',
async () => {
chmodSync(dir, 0o555);
try {
const lock = await acquireIndexLock(dir, { timeoutMs: 2000 });
expect(typeof lock.release).toBe('function');
expect(() => lock.release()).not.toThrow();
expect(existsSync(lockPath())).toBe(false); // lock file was never created
} finally {
chmodSync(dir, 0o755);
}
},
);
});