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
+21 -5
View File
@@ -1044,20 +1044,32 @@ let candidateRefreshInFlight = false;
export const refreshActiveConnectionCandidates = async (): Promise<CandidateRefreshResult> => {
if (candidateRefreshInFlight) return 'skipped';
const active = findActiveConnection();
if (!active) return 'skipped';
if (!active) {
logConnect('candidates:refresh-skip', { reason: 'no-active-connection' });
return 'skipped';
}
const relay = relayCandidateOf(active);
if (!relay) return 'skipped';
if (!relay) {
logConnect('candidates:refresh-skip', { reason: 'no-relay-candidate' });
return 'skipped';
}
candidateRefreshInFlight = true;
try {
const response = await raceWithTimeout(
RELAY_CONNECT_TIMEOUT_MS,
runtimeFetch('/api/client-auth/connection/candidates').then((r): Response | null => r).catch(() => null),
);
if (!response?.ok) return 'skipped';
if (!response?.ok) {
logConnect('candidates:refresh-skip', { reason: 'fetch-failed', status: response?.status ?? null });
return 'skipped';
}
const payload = await response.json().catch(() => null) as { serverId?: unknown; candidates?: unknown } | null;
// Identity gate: the refresh must come from the server this device paired
// with. Old servers (no serverId) are skipped rather than trusted blindly.
if (!payload || payload.serverId !== relay.serverId) return 'skipped';
if (!payload || payload.serverId !== relay.serverId) {
logConnect('candidates:refresh-skip', { reason: 'server-id-mismatch' });
return 'skipped';
}
const reported = Array.isArray(payload.candidates) ? payload.candidates : [];
const lanUrls: string[] = [];
for (const entry of reported) {
@@ -1074,7 +1086,10 @@ export const refreshActiveConnectionCandidates = async (): Promise<CandidateRefr
// No LAN reported (loopback-only bind or interface-scan failure): keep the
// existing candidates — deleting them on a possibly-transient empty answer
// would be silent data loss; a stale entry only costs one fast probe.
if (lanUrls.length === 0) return 'skipped';
if (lanUrls.length === 0) {
logConnect('candidates:refresh-skip', { reason: 'no-lan-reported' });
return 'skipped';
}
const preservedHttps = directCandidates(active).filter((candidate) => candidate.url.startsWith('https://'));
const next: MobileTransportCandidate[] = [
...lanUrls.map((url): MobileTransportCandidate => ({ kind: 'direct', url })),
@@ -1102,6 +1117,7 @@ const scheduleCandidateRefresh = (): void => {
window.setTimeout(() => {
void (async () => {
const result = await refreshActiveConnectionCandidates().catch((): CandidateRefreshResult => 'skipped');
logConnect('candidates:refresh-result', { result });
if (result === 'updated' && isRelayModeActive()) {
await reprobeActiveConnection().catch(() => null);
}
+9
View File
@@ -93,6 +93,7 @@ import { createClientPairingRuntime } from './lib/client-auth/pairing.js';
import { createPreviewProxyRuntime } from './lib/preview/proxy-runtime.js';
import { attachRealtimeProxy } from './lib/realtime-proxy.js';
import { createRelayService } from './lib/relay/service.js';
import { createRelayHostLock } from './lib/relay/host-lock.js';
import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware';
import webPush from 'web-push';
@@ -1476,6 +1477,14 @@ async function main(options = {}) {
readSettingsStrict: readSettingsFromDiskStrict,
remoteClientAuthRuntime,
getLocalPort: () => tunnelRuntimeContext.getActivePort(),
// One relay host per machine: every instance sharing this data dir shares
// the relay identity (serverId), so concurrent hosts evict each other at
// the relay worker and devices land on a random local instance.
hostLock: createRelayHostLock({
lockFilePath: path.join(OPENCHAMBER_DATA_DIR, 'relay-host.lock'),
fs,
process,
}),
// Relay demand = any paired device or pending pairing session that uses the
// relay transport. Drives the auto on/off lifecycle.
hasRelayDemand: async () => {
@@ -23,6 +23,7 @@ Host side (`packages/web/server/lib/relay/`):
- `identity.js` — the host's stable identity: the long-lived signing keypair (shared with the push relay, defines the routing id) plus a long-lived encryption keypair (the E2EE trust anchor). Reused across restarts; never rotated implicitly.
- `signing-key.js` — storage/derivation of the signing keypair and the routing id, shared with the notifications runtime.
- `host-client.js` — the long-lived connection manager: one outbound control connection to the relay, a per-client data connection for each connected device, reconnect/backoff, and the E2EE responder handshake per connection.
- `host-lock.js` — the per-machine host claim. Every local instance sharing the data dir shares the relay identity (same serverId), so concurrent relay hosts evict each other at the relay worker (`4001: Control replaced`) and paired devices land on whichever local process won last. The claim file (`<data-dir>/relay-host.lock`, `{ pid }`) makes this deterministic: `service.js` only starts the host when no LIVE process holds the claim (stale claims from dead pids are ignored), goes to `standby` otherwise, and a 30s watcher both takes over when the claimant dies and stands down when another process claims. Explicit user intent — creating a pairing link or hitting `/relay/enable` — force-claims; the previous holder's watcher sees the takeover and backs off instead of fighting. The claim is cooperative (the relay worker still enforces the single host slot); it only decides which process keeps retrying.
- `tunnel-host.js` — the per-connection dispatcher: decrypts tunnel frames and forwards HTTP/SSE/WS to the local server over loopback, then streams responses back. Enforces a path allowlist and never injects credentials.
- `e2ee.js`, `tunnel-codec.js` — host-side (JS) mirrors of the shared crypto and framing (see "Two implementations" below).
+101
View File
@@ -0,0 +1,101 @@
// 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 };
};
@@ -0,0 +1,129 @@
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);
});
});
+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' });