Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/devframe/src/client/rpc-live-trust.test.ts
Original file line number Diff line number Diff line change
@@ -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<DevframeRpcClientFunctions, DevframeRpcContext>({ rpc: undefined! })
return createLiveRpcClientMode({
transport: 'websocket',
connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } },
events: createEventEmitter<RpcClientEvents>(),
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)
})
})
27 changes: 15 additions & 12 deletions packages/devframe/src/client/rpc-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof setTimeout> | undefined
try {
await Promise.race([
trustedPromise.promise,
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error('[devframe] Timeout waiting for rpc to be trusted'))
}, timeout)
}),
])
return isTrusted
}
finally {
clearTimeout(timer)
}
}

return {
Expand Down
30 changes: 30 additions & 0 deletions packages/devframe/src/client/rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand Down
20 changes: 16 additions & 4 deletions packages/devframe/src/client/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -495,10 +510,7 @@ export async function getDevframeRpcClient(
streaming: undefined!,
cacheManager,
scope: undefined!,
close: () => {
disposeWebMcp?.()
mode.close?.()
},
close: closeRpcClient,
}

rpc.sharedState = createRpcSharedStateClientHost(rpc)
Expand Down
Loading