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