From a3f967755af99a0a0d8dd82b7b5d4f3dd4aec0bc Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Sun, 13 Sep 2026 13:32:43 +0200 Subject: [PATCH] fix: dispose RPC authentication channel and trust deadlines --- .../src/client/rpc-live-trust.test.ts | 67 +++++++++++++++++++ packages/devframe/src/client/rpc-live.ts | 27 ++++---- packages/devframe/src/client/rpc.test.ts | 30 +++++++++ packages/devframe/src/client/rpc.ts | 20 ++++-- 4 files changed, 128 insertions(+), 16 deletions(-) create mode 100644 packages/devframe/src/client/rpc-live-trust.test.ts diff --git a/packages/devframe/src/client/rpc-live-trust.test.ts b/packages/devframe/src/client/rpc-live-trust.test.ts new file mode 100644 index 000000000..de7841dbd --- /dev/null +++ b/packages/devframe/src/client/rpc-live-trust.test.ts @@ -0,0 +1,67 @@ +import type { DevframeRpcClientFunctions } from 'devframe/types' +import type { DevframeClientRpcHost, DevframeRpcContext, RpcClientEvents } from './rpc' +import { RpcFunctionsCollectorBase } from 'devframe/rpc' +import { createEventEmitter } from 'devframe/utils/events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createLiveRpcClientMode } from './rpc-live' + +vi.mock('devframe/rpc/client', () => ({ + createRpcClient: () => ({ $call: vi.fn(async () => ({ isTrusted: true })) }), +})) + +function createMode() { + const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase({ rpc: undefined! }) + return createLiveRpcClientMode({ + transport: 'websocket', + connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } }, + events: createEventEmitter(), + clientRpc, + createChannel: () => ({ post: vi.fn(), on: vi.fn(), close: vi.fn() }), + }) +} + +describe('trust deadline cleanup', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('navigator', { userAgent: 'test' }) + vi.stubGlobal('location', { origin: 'http://localhost' }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('clears concurrent deadlines as soon as authentication succeeds', async () => { + expect.assertions(4) + const mode = createMode() + const first = mode.ensureTrusted(60_000) + const second = mode.ensureTrusted(30_000) + expect(vi.getTimerCount()).toBe(2) + await mode.requestTrustWithToken('test-token') + await expect(first).resolves.toBe(true) + await expect(second).resolves.toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) + + it('leaves no deadline behind when already trusted', async () => { + expect.assertions(2) + const mode = createMode() + await mode.requestTrustWithToken('test-token') + await expect(mode.ensureTrusted()).resolves.toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) + + it('preserves expiry and unlimited trust waits', async () => { + expect.assertions(4) + const mode = createMode() + const unlimited = mode.ensureTrusted(0) + expect(vi.getTimerCount()).toBe(0) + const expiry = expect(mode.ensureTrusted(10)).rejects.toThrow('Timeout waiting for rpc to be trusted') + await vi.advanceTimersByTimeAsync(10) + await expiry + expect(vi.getTimerCount()).toBe(0) + await mode.requestTrustWithToken('test-token') + await expect(unlimited).resolves.toBe(true) + }) +}) diff --git a/packages/devframe/src/client/rpc-live.ts b/packages/devframe/src/client/rpc-live.ts index 00c050568..f07723624 100644 --- a/packages/devframe/src/client/rpc-live.ts +++ b/packages/devframe/src/client/rpc-live.ts @@ -261,18 +261,21 @@ export function createLiveRpcClientMode( if (timeout <= 0) return trustedPromise.promise - let clear = () => {} - await Promise.race([ - trustedPromise.promise.then(clear), - new Promise((resolve, reject) => { - const id = setTimeout(() => { - reject(new Error('[devframe] Timeout waiting for rpc to be trusted')) - }, timeout) - clear = () => clearTimeout(id) - }), - ]) - - return isTrusted + let timer: ReturnType | undefined + try { + await Promise.race([ + trustedPromise.promise, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error('[devframe] Timeout waiting for rpc to be trusted')) + }, timeout) + }), + ]) + return isTrusted + } + finally { + clearTimeout(timer) + } } return { diff --git a/packages/devframe/src/client/rpc.test.ts b/packages/devframe/src/client/rpc.test.ts index 6d11ab369..b70792d4f 100644 --- a/packages/devframe/src/client/rpc.test.ts +++ b/packages/devframe/src/client/rpc.test.ts @@ -57,6 +57,36 @@ describe('getDevframeRpcClient: connection meta base', () => { delete (globalThis as any)[DEVFRAME_CONNECTION_KEY] }) + it('closes the authentication broadcast channel with the RPC client', async () => { + expect.assertions(1) + const closeChannel = vi.spyOn(FakeBroadcastChannel.prototype, 'close') + const rpc = await getDevframeRpcClient({ + connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } }, + otpParam: false, + simpleAuth: false, + webmcp: false, + }) + rpc.close?.() + expect(closeChannel).toHaveBeenCalledExactlyOnceWith() + }) + + it('still closes the transport when closing the authentication channel fails', async () => { + expect.assertions(2) + const failure = new Error('channel cleanup failed') + vi.spyOn(FakeBroadcastChannel.prototype, 'close').mockImplementation(() => { + throw failure + }) + const closeTransport = vi.spyOn(FakeWebSocket.prototype, 'close') + const rpc = await getDevframeRpcClient({ + connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } }, + otpParam: false, + simpleAuth: false, + webmcp: false, + }) + expect(() => rpc.close?.()).toThrow(failure) + expect(closeTransport).toHaveBeenCalledExactlyOnceWith() + }) + it('publishes the meta annotated with the absolute base it resolved from', async () => { const served: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } } vi.stubGlobal('fetch', vi.fn(async () => ({ diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index c195a772d..ee41fd051 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -445,6 +445,21 @@ export async function getDevframeRpcClient( }) as F } + /** Release authentication and transport resources even if another disposer fails. */ + function closeRpcClient(): void { + try { + disposeWebMcp?.() + } + finally { + try { + authChannel?.close() + } + finally { + mode.close?.() + } + } + } + const rpc: DevframeRpcClient = { events, get isTrusted() { @@ -495,10 +510,7 @@ export async function getDevframeRpcClient( streaming: undefined!, cacheManager, scope: undefined!, - close: () => { - disposeWebMcp?.() - mode.close?.() - }, + close: closeRpcClient, } rpc.sharedState = createRpcSharedStateClientHost(rpc)