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
+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 () => {