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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user