130 lines
4.4 KiB
JavaScript
130 lines
4.4 KiB
JavaScript
import { describe, expect, it } from 'bun:test';
|
|||
|
|
|
||
|
|
import { createRelayHostLock } from './host-lock.js';
|
||
|
|
|
||
|
|
// In-memory fs standing in for the shared data dir.
|
||
|
|
const makeFakeFs = () => {
|
||
|
|
const files = new Map();
|
||
|
|
return {
|
||
|
|
readFileSync: (p) => {
|
||
|
|
if (!files.has(p)) {
|
||
|
|
const error = new Error('ENOENT');
|
||
|
|
error.code = 'ENOENT';
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
return files.get(p);
|
||
|
|
},
|
||
|
|
writeFileSync: (p, data) => {
|
||
|
|
files.set(p, data);
|
||
|
|
},
|
||
|
|
unlinkSync: (p) => {
|
||
|
|
files.delete(p);
|
||
|
|
},
|
||
|
|
peek: (p) => files.get(p),
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
// Fake process: `alive` is the set of pids that respond to kill(pid, 0).
|
||
|
|
const makeFakeProcess = (pid, alive = new Set([pid])) => ({
|
||
|
|
pid,
|
||
|
|
kill: (target) => {
|
||
|
|
if (!alive.has(target)) {
|
||
|
|
const error = new Error('ESRCH');
|
||
|
|
error.code = 'ESRCH';
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const LOCK = '/data/relay-host.lock';
|
||
|
|
const silentLogger = { warn: () => {} };
|
||
|
|
|
||
|
|
describe('relay host lock', () => {
|
||
|
|
it('claims a free lock and reports holding it', () => {
|
||
|
|
const fs = makeFakeFs();
|
||
|
|
const lock = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(100), logger: silentLogger });
|
||
|
|
|
||
|
|
expect(lock.tryClaim()).toBe(true);
|
||
|
|
expect(lock.holdsClaim()).toBe(true);
|
||
|
|
expect(JSON.parse(fs.peek(LOCK)).pid).toBe(100);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('refuses to claim while another LIVE process holds it', () => {
|
||
|
|
const fs = makeFakeFs();
|
||
|
|
const alive = new Set([100, 200]);
|
||
|
|
const first = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(100, alive), logger: silentLogger });
|
||
|
|
const second = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(200, alive), logger: silentLogger });
|
||
|
|
|
||
|
|
expect(first.tryClaim()).toBe(true);
|
||
|
|
expect(second.tryClaim()).toBe(false);
|
||
|
|
expect(second.holdsClaim()).toBe(false);
|
||
|
|
expect(second.liveClaimantPid()).toBe(100);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('treats a dead claimant as free (stale claim takeover)', () => {
|
||
|
|
const fs = makeFakeFs();
|
||
|
|
const alive = new Set([200]); // pid 100 is gone
|
||
|
|
fs.writeFileSync(LOCK, JSON.stringify({ pid: 100 }));
|
||
|
|
const lock = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(200, alive), logger: silentLogger });
|
||
|
|
|
||
|
|
expect(lock.liveClaimantPid()).toBe(null);
|
||
|
|
expect(lock.tryClaim()).toBe(true);
|
||
|
|
expect(lock.holdsClaim()).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('forceClaim overrides a live holder; the loser sees the takeover', () => {
|
||
|
|
const fs = makeFakeFs();
|
||
|
|
const alive = new Set([100, 200]);
|
||
|
|
const first = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(100, alive), logger: silentLogger });
|
||
|
|
const second = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(200, alive), logger: silentLogger });
|
||
|
|
|
||
|
|
expect(first.tryClaim()).toBe(true);
|
||
|
|
expect(second.forceClaim()).toBe(true);
|
||
|
|
expect(second.holdsClaim()).toBe(true);
|
||
|
|
expect(first.holdsClaim()).toBe(false);
|
||
|
|
expect(first.liveClaimantPid()).toBe(200);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('release removes only its own claim', () => {
|
||
|
|
const fs = makeFakeFs();
|
||
|
|
const alive = new Set([100, 200]);
|
||
|
|
const first = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(100, alive), logger: silentLogger });
|
||
|
|
const second = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(200, alive), logger: silentLogger });
|
||
|
|
|
||
|
|
expect(first.tryClaim()).toBe(true);
|
||
|
|
second.release(); // not the holder — must be a no-op
|
||
|
|
expect(first.holdsClaim()).toBe(true);
|
||
|
|
|
||
|
|
first.release();
|
||
|
|
expect(fs.peek(LOCK)).toBeUndefined();
|
||
|
|
expect(first.liveClaimantPid()).toBe(null);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('treats an unparsable claim file as free', () => {
|
||
|
|
const fs = makeFakeFs();
|
||
|
|
fs.writeFileSync(LOCK, 'not-json');
|
||
|
|
const lock = createRelayHostLock({ lockFilePath: LOCK, fs, process: makeFakeProcess(100), logger: silentLogger });
|
||
|
|
|
||
|
|
expect(lock.liveClaimantPid()).toBe(null);
|
||
|
|
expect(lock.tryClaim()).toBe(true);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('an EPERM kill probe still counts as a live holder', () => {
|
||
|
|
const fs = makeFakeFs();
|
||
|
|
fs.writeFileSync(LOCK, JSON.stringify({ pid: 100 }));
|
||
|
|
const proc = {
|
||
|
|
pid: 200,
|
||
|
|
kill: () => {
|
||
|
|
const error = new Error('EPERM');
|
||
|
|
error.code = 'EPERM';
|
||
|
|
throw error;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const lock = createRelayHostLock({ lockFilePath: LOCK, fs, process: proc, logger: silentLogger });
|
||
|
|
|
||
|
|
expect(lock.liveClaimantPid()).toBe(100);
|
||
|
|
expect(lock.tryClaim()).toBe(false);
|
||
|
|
});
|
||
|
|
});
|