All local instances share the data dir and therefore the relay identity
(serverId), so concurrent relay hosts evicted each other at the relay worker
(4001: Control replaced) and paired devices landed on whichever local process
won last — often a stale dev server, surfacing as 'Unable to reach server'
and devices stuck on relay with 503s on newer endpoints.
- relay/host-lock.js: per-machine claim file (relay-host.lock, {pid}); stale
claims from dead pids are ignored; unwritable data dir falls back to
pre-lock behavior
- relay/service.js: start only when the claim is free or ours, otherwise
'standby' with the holder pid in lastError; 30s watcher takes over when the
claimant dies and stands down when another process claims; pairing-link
creation and explicit /relay/enable force-claim (user intent wins)
- mobileConnections.ts: log candidate-refresh skip reasons and the refresh
result instead of failing silently
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);
|
|
});
|
|
});
|