fix(relay): stop bystander instances from capturing the relay host
Two mitigations for local multi-instance contention over the shared relay identity: - A standby instance now waits a 2-minute grace period after the host claim frees before taking over, so a cleanly restarting host (app update or relaunch) — which reclaims at boot with no wait — always wins the restart window instead of stranding paired devices on another process. - Dev instances never host the relay passively: dev scripts set OPENCHAMBER_RELAY_HOST=off and the Electron dev shell is detected via OPENCHAMBER_ELECTRON_DEV. Explicit enable/pairing on such an instance still force-claims; OPENCHAMBER_RELAY_HOST=on overrides.
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { createRelayService } from './service.js';
|
||||
|
||||
const makeService = (options = {}) => {
|
||||
// In-memory settings store with a pre-seeded relay identity so the service
|
||||
// never regenerates a signing key during the test.
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' });
|
||||
let settings = {
|
||||
relaySigningKey: {
|
||||
privateJwk: privateKey.export({ format: 'jwk' }),
|
||||
publicJwk: publicKey.export({ format: 'jwk' }),
|
||||
},
|
||||
privateRelay: { enabled: true, relayUrl: 'wss://relay.example.test/ws' },
|
||||
...options.settings,
|
||||
};
|
||||
const hostLock = {
|
||||
tryClaim: vi.fn(() => true),
|
||||
forceClaim: vi.fn(() => true),
|
||||
holdsClaim: vi.fn(() => true),
|
||||
liveClaimantPid: vi.fn(() => null),
|
||||
release: vi.fn(),
|
||||
};
|
||||
const service = createRelayService({
|
||||
crypto,
|
||||
readSettingsFromDiskMigrated: async () => settings,
|
||||
writeSettingsToDisk: async (next) => { settings = next; },
|
||||
readSettingsStrict: async () => settings,
|
||||
getLocalPort: () => 0,
|
||||
hasRelayDemand: options.hasRelayDemand ?? (async () => true),
|
||||
hostLock,
|
||||
allowPassiveHost: options.allowPassiveHost,
|
||||
logger: { warn: () => {} },
|
||||
});
|
||||
return { service, hostLock, getSettings: () => settings };
|
||||
};
|
||||
|
||||
describe('relay service passive hosting', () => {
|
||||
it('never claims or starts the host passively when passive hosting is disabled', async () => {
|
||||
const { service, hostLock } = makeService({ allowPassiveHost: false });
|
||||
try {
|
||||
await service.startIfEnabled();
|
||||
let status = await service.getStatus();
|
||||
expect(status.state).toBe('standby');
|
||||
expect(hostLock.tryClaim).not.toHaveBeenCalled();
|
||||
expect(hostLock.forceClaim).not.toHaveBeenCalled();
|
||||
|
||||
await service.reconcile();
|
||||
status = await service.getStatus();
|
||||
expect(status.state).toBe('standby');
|
||||
expect(status.lastError).toContain('passive relay hosting is disabled');
|
||||
expect(hostLock.tryClaim).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
service.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it('force-claims for an explicit pairing even when passive hosting is disabled', async () => {
|
||||
const { service, hostLock } = makeService({ allowPassiveHost: false });
|
||||
try {
|
||||
const candidate = await service.ensureEnabledForPairing();
|
||||
expect(candidate?.type).toBe('relay');
|
||||
expect(hostLock.forceClaim).toHaveBeenCalled();
|
||||
} finally {
|
||||
service.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user