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
102 lines
3.8 KiB
JavaScript
102 lines
3.8 KiB
JavaScript
// Per-machine relay-host claim. Every OpenChamber instance on a machine shares
|
|
// the same data dir and therefore the same relay signing key / serverId, so if
|
|
// two processes run a relay host at once they fight over the single host slot
|
|
// at the relay worker (each new connection closes the previous one with
|
|
// "4001: Control replaced") and paired devices land on whichever instance won
|
|
// last — often a dev/worktree instance running different code.
|
|
//
|
|
// The claim file (`relay-host.lock` in the shared data dir) makes the contest
|
|
// deterministic instead of a network race:
|
|
// - an instance only starts its relay host when there is no LIVE claimant
|
|
// (a dead claimant's stale file is ignored);
|
|
// - explicit user intent (creating a pairing link) claims unconditionally —
|
|
// the instance the user is interacting with must be the one devices reach;
|
|
// - a running host that discovers another live process has claimed backs off
|
|
// instead of reconnecting, which is what ends the replace/reconnect fight.
|
|
//
|
|
// This is a cooperative claim, not an OS lock: correctness does not depend on
|
|
// atomicity (the relay worker still enforces a single host); the claim only
|
|
// decides which process KEEPS retrying and which stands down.
|
|
|
|
/**
|
|
* @param {{
|
|
* lockFilePath: string,
|
|
* fs?: typeof import('node:fs'),
|
|
* process?: NodeJS.Process,
|
|
* logger?: Pick<Console, 'warn'>,
|
|
* }} deps
|
|
*/
|
|
export const createRelayHostLock = ({ lockFilePath, fs, process: proc, logger = console }) => {
|
|
const fsImpl = fs;
|
|
const selfPid = proc.pid;
|
|
|
|
const isPidAlive = (pid) => {
|
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
try {
|
|
proc.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
// EPERM means the process exists but belongs to another user — treat as
|
|
// alive; only ESRCH (no such process) means the claim is stale.
|
|
return error?.code === 'EPERM';
|
|
}
|
|
};
|
|
|
|
const readClaim = () => {
|
|
try {
|
|
const raw = fsImpl.readFileSync(lockFilePath, 'utf8');
|
|
const parsed = JSON.parse(raw);
|
|
const pid = Number(parsed?.pid);
|
|
return Number.isInteger(pid) && pid > 0 ? { pid } : null;
|
|
} catch {
|
|
// Missing file or unparsable content: no valid claim.
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const writeClaim = () => {
|
|
try {
|
|
fsImpl.writeFileSync(lockFilePath, JSON.stringify({ pid: selfPid, claimedAt: new Date().toISOString() }));
|
|
return true;
|
|
} catch (error) {
|
|
// An unwritable data dir must not take the relay down with it — fall back
|
|
// to pre-lock behavior (start the host, let the relay worker arbitrate).
|
|
logger.warn(`[Relay] could not write host claim file: ${error?.message ?? error}`);
|
|
return true;
|
|
}
|
|
};
|
|
|
|
/** The pid of the current live claimant, or null when the claim is free/stale. */
|
|
const liveClaimantPid = () => {
|
|
const claim = readClaim();
|
|
if (!claim) return null;
|
|
return isPidAlive(claim.pid) ? claim.pid : null;
|
|
};
|
|
|
|
/** Claim unless another LIVE process already holds it. Re-claiming our own is a no-op refresh. */
|
|
const tryClaim = () => {
|
|
const holder = liveClaimantPid();
|
|
if (holder !== null && holder !== selfPid) return false;
|
|
return writeClaim();
|
|
};
|
|
|
|
/** Unconditional claim — explicit user intent (pairing) overrides any holder. */
|
|
const forceClaim = () => writeClaim();
|
|
|
|
/** True while this process is the live claimant. */
|
|
const holdsClaim = () => liveClaimantPid() === selfPid;
|
|
|
|
/** Release only our own claim; never delete another process's. */
|
|
const release = () => {
|
|
const claim = readClaim();
|
|
if (!claim || claim.pid !== selfPid) return;
|
|
try {
|
|
fsImpl.unlinkSync(lockFilePath);
|
|
} catch {
|
|
// Already gone or unwritable — nothing to do.
|
|
}
|
|
};
|
|
|
|
return { tryClaim, forceClaim, holdsClaim, liveClaimantPid, release };
|
|
};
|