From 13eee7eacf8a6fcd4bb77ff680d18f727fca1171 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 10 Sep 2026 16:38:00 -0700 Subject: [PATCH] fix(relay): name the LAN flip that lands mid-mint, not relay_control_not_active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit withTransientDemand checks the host pairing policy once, at entry. But RelayDemandLedger.hasDemand filters the in-flight operation's OWN transient ref through the live policy, so a flip to local-only while createPairingRelay or provisionRelay is awaiting withdraws the demand that operation is holding. The coordinator then reaches its no-demand branch and publishes 'standby', publish() clears offlineReason to null for any non-offline status, and the broker wait returns with no cause at all — relay_control_not_active, the generic code this stack exists to remove. The flip is exactly the cause and relay_disabled_for_device is already its word; the entry gate just could not see a flip that had not happened yet. Re-ask the policy on the failure path rather than trusting the thrown code. Reproduced first: the added test failed with "expected ... to throw error including 'relay_disabled_for_device' but got 'relay_control_not_active'". Controls cover the two ways this could over-reach — a failure the policy had nothing to do with is not rewritten, and a grant that succeeded under a flip is left alone. Top follow-up found alongside this and deliberately not fixed here: flushRevoke swallows every error with a bare catch, no attempt cap and no item expiry, while the revoke check in hasDemand is deliberately not filtered through the policy. A revoke that fails permanently server-side therefore holds demand open forever and defeats the LAN pick entirely — the same symptom this change is part of closing, by a different route, with no test coverage. --- ...op-relay-service-policy-flip-cause.test.ts | 89 +++++++++++++++++++ .../runtime/relay/desktop-relay-service.ts | 9 ++ 2 files changed, 98 insertions(+) create mode 100644 src/main/runtime/relay/desktop-relay-service-policy-flip-cause.test.ts diff --git a/src/main/runtime/relay/desktop-relay-service-policy-flip-cause.test.ts b/src/main/runtime/relay/desktop-relay-service-policy-flip-cause.test.ts new file mode 100644 index 00000000000..ae15f70b4d4 --- /dev/null +++ b/src/main/runtime/relay/desktop-relay-service-policy-flip-cause.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import { DesktopRelayService } from './desktop-relay-service' +import type { MobilePairingConnectionMode } from '../../../shared/mobile-pairing-connection-mode' + +/** + * A host LAN flip lands mid-mint. + * + * `hasDemand` filters the in-flight operation's OWN transient ref through the live policy + * (relay-demand-ledger.ts:50-54), so the flip withdraws the demand that operation is holding. + * The coordinator then publishes `standby`, which clears `offlineReason` to null, and the broker + * wait returns with no cause — `relay_control_not_active`, the generic code this stack exists to + * remove. The flip is the cause and `relay_disabled_for_device` is already its word. + */ +type TransientDemandHost = { + withTransientDemand: ( + kind: string, + deviceId: string, + operation: () => Promise + ) => Promise +} + +function serviceWithMode(mode: { current: MobilePairingConnectionMode }): { + run: (operation: () => Promise) => Promise + release: ReturnType +} { + const release = vi.fn() + const service = Object.create(DesktopRelayService.prototype) as DesktopRelayService + Object.assign(service, { + hostMobilePairingConnectionMode: () => mode.current, + runtimeRpc: { + getDeviceRegistry: () => ({ getMobilePairingConnectionMode: () => 'automatic' }) + }, + demandLedger: { acquireTransient: () => release }, + refreshDemand: () => {} + }) + const host = service as unknown as TransientDemandHost + return { + run: (operation) => host.withTransientDemand.call(service, 'pairing', 'device-1', operation), + release + } +} + +describe('DesktopRelayService policy flip during an in-flight grant', () => { + it('names the LAN flip rather than the generic control-not-active code', async () => { + const mode = { current: 'automatic' as MobilePairingConnectionMode } + const { run, release } = serviceWithMode(mode) + + await expect( + run(async () => { + mode.current = 'local-only' + // What requireActiveBroker throws once the flip cleared the offline reason. + throw new Error('relay_control_not_active') + }) + ).rejects.toThrow('relay_disabled_for_device') + expect(release).toHaveBeenCalled() + }) + + it('does not rewrite a failure the policy had nothing to do with', async () => { + const mode = { current: 'automatic' as MobilePairingConnectionMode } + const { run } = serviceWithMode(mode) + + await expect( + run(async () => { + throw new Error('relay_broker_unavailable') + }) + ).rejects.toThrow('relay_broker_unavailable') + }) + + it('still refuses at the entry gate when the policy already excludes the device', async () => { + const mode = { current: 'local-only' as MobilePairingConnectionMode } + const { run } = serviceWithMode(mode) + const operation = vi.fn(async () => 'unreachable') + + await expect(run(operation)).rejects.toThrow('relay_disabled_for_device') + expect(operation).not.toHaveBeenCalled() + }) + + it('leaves a successful grant alone even if the policy flips under it', async () => { + const mode = { current: 'automatic' as MobilePairingConnectionMode } + const { run } = serviceWithMode(mode) + + await expect( + run(async () => { + mode.current = 'local-only' + return 'minted' + }) + ).resolves.toBe('minted') + }) +}) diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index be9b22bb0c4..f108fda2968 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -295,6 +295,15 @@ export class DesktopRelayService { this.refreshDemand() try { return await operation() + } catch (error) { + // Why re-ask instead of trusting the thrown code: a flip lands mid-operation, and + // `hasDemand` filters this ref's own demand through the live policy, so the coordinator + // reaches `standby` and clears the offline reason. The wait then ends with no cause at all + // — the generic `relay_control_not_active` — when the flip is exactly the cause. + if (!this.isRelayAllowedForDevice(deviceId)) { + throw new Error('relay_disabled_for_device') + } + throw error } finally { release() this.refreshDemand()