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
This commit is contained in:
Bohdan Triapitsyn
2026-07-13 00:36:00 +03:00
parent e247343423
commit 04307e163b
6 changed files with 342 additions and 12 deletions
+81 -7
View File
@@ -65,12 +65,21 @@ export const createRelayService = ({
// Returns true when any paired device or pending pairing session uses the
// relay transport. The relay lifecycle is driven purely by this demand.
hasRelayDemand = async () => false,
// Per-machine claim (host-lock.js): all local instances share the same
// serverId, so only ONE process may run the relay host at a time or they
// evict each other at the relay worker ("Control replaced") and devices land
// on a random instance. Optional: without it, behavior is pre-lock.
hostLock = null,
logger = console,
}) => {
const identityRuntime = createRelayIdentityRuntime({ crypto, readSettingsFromDiskMigrated, writeSettingsToDisk, readSettingsStrict });
let hostClient = null;
let status = { state: 'disabled', lastError: null, connectedClients: 0 };
// Re-checks the claim while enabled: a standby instance takes over when the
// claimant dies; a running host stands down when another process claims.
let claimWatchTimer = null;
const CLAIM_WATCH_INTERVAL_MS = 30_000;
const readConfig = async () => {
const settings = await readSettingsFromDiskMigrated();
@@ -93,8 +102,65 @@ export const createRelayService = ({
});
};
const start = async (relayUrl) => {
const stopHostClient = () => {
if (!hostClient) return;
hostClient.stop();
hostClient = null;
};
const standbyStatus = (holderPid) => ({
state: 'standby',
lastError: `relay host is owned by another local OpenChamber process (pid ${holderPid})`,
connectedClients: 0,
});
// Claim watcher, active while the relay is enabled:
// - standby → claimant died → take over (start our host);
// - running → another live process claimed → stand down (stop, standby).
// This back-off is what actually ends the mutual-eviction fight: the loser
// must STOP reconnecting, otherwise both keep replacing each other forever.
const ensureClaimWatch = (relayUrl) => {
if (!hostLock || claimWatchTimer) return;
claimWatchTimer = setInterval(() => {
void (async () => {
try {
if (hostClient) {
if (!hostLock.holdsClaim() && hostLock.liveClaimantPid() !== null) {
logger.warn('[Relay] host claim taken by another local instance — standing down');
const holder = hostLock.liveClaimantPid();
stopHostClient();
status = standbyStatus(holder);
}
return;
}
if (status.state === 'standby' && hostLock.tryClaim()) {
logger.warn('[Relay] host claim is free — taking over the relay host');
await start(relayUrl);
}
} catch (error) {
logger.warn(`[Relay] claim watch failed: ${error?.message ?? error}`);
}
})();
}, CLAIM_WATCH_INTERVAL_MS);
if (typeof claimWatchTimer.unref === 'function') claimWatchTimer.unref();
};
const stopClaimWatch = () => {
if (!claimWatchTimer) return;
clearInterval(claimWatchTimer);
claimWatchTimer = null;
};
const start = async (relayUrl, { claim = 'try' } = {}) => {
if (hostClient) return;
if (hostLock) {
const claimed = claim === 'force' ? hostLock.forceClaim() : hostLock.tryClaim();
if (!claimed) {
status = standbyStatus(hostLock.liveClaimantPid());
ensureClaimWatch(relayUrl);
return;
}
}
const identity = await identityRuntime.getRelayIdentity();
hostClient = startRelayHost({
relayUrl,
@@ -106,12 +172,13 @@ export const createRelayService = ({
},
});
status = hostClient.getStatus();
ensureClaimWatch(relayUrl);
};
const stop = () => {
if (!hostClient) return;
hostClient.stop();
hostClient = null;
stopClaimWatch();
stopHostClient();
if (hostLock) hostLock.release();
status = { state: 'disabled', lastError: null, connectedClients: 0 };
};
@@ -163,7 +230,9 @@ export const createRelayService = ({
const live = hostClient ? hostClient.getStatus() : status;
return {
enabled: config.enabled,
state: hostClient ? live.state : 'disabled',
// Without a host client the service is either off or standing by while
// another local process owns the machine's relay host claim.
state: hostClient ? live.state : (status.state === 'standby' ? 'standby' : 'disabled'),
serverId: identity.serverId,
connectedClients: live.connectedClients,
relayUrl: config.relayUrl,
@@ -207,7 +276,11 @@ export const createRelayService = ({
}
if (!hostClient) {
const next = await readConfig();
await start(next.relayUrl);
// Force-claim: creating a pairing link is explicit user intent — the
// instance the user is pairing against MUST be the one devices reach,
// even if another local process currently holds the machine's claim
// (its claim watcher sees the takeover and stands down).
await start(next.relayUrl, { claim: 'force' });
}
return buildPairingCandidate();
};
@@ -227,7 +300,8 @@ export const createRelayService = ({
const relayUrl = typeof req.body?.relayUrl === 'string' ? normalizeRelayUrl(req.body.relayUrl) : current.relayUrl;
await writeConfig({ enabled: true, relayUrl });
if (hostClient) stop();
await start(relayUrl);
// Explicit user action: take the machine's host claim like pairing does.
await start(relayUrl, { claim: 'force' });
res.json(await getStatus());
} catch (error) {
res.status(500).json({ error: error?.message ?? 'Failed to enable relay' });