Files
Bohdan Triapitsyn 04307e163b fix: single relay host per machine via cooperative claim lock
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
2026-07-13 00:36:00 +03:00

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 };
};