Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # packages/client/ui-conversation/src/client/queue/QueueDock.tsx
This commit is contained in:
@@ -3,15 +3,55 @@
|
||||
* selection off the page URL, and the single-consumer stream-loop ownership.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { apply, type ConnectionHandle } from '../src/client/index.ts'
|
||||
import type { RpcMessage } from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
import { FixtureApiClient } from '../src/client/fixture.ts'
|
||||
import { WebApiClient } from '../src/client/web-api-client.ts'
|
||||
|
||||
type Win = { location?: { hostname: string; search: string } }
|
||||
type Win = { location?: { hostname: string; search: string; origin?: string } }
|
||||
type WebSocketGlobal = { WebSocket?: typeof WebSocket }
|
||||
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
const sockets: FakeWebSocket[] = []
|
||||
|
||||
class FakeWebSocket extends EventTarget {
|
||||
static readonly CONNECTING = 0
|
||||
static readonly OPEN = 1
|
||||
static readonly CLOSING = 2
|
||||
static readonly CLOSED = 3
|
||||
|
||||
readonly url: string
|
||||
readyState = FakeWebSocket.CONNECTING
|
||||
|
||||
constructor(url: string | URL) {
|
||||
super()
|
||||
this.url = String(url)
|
||||
sockets.push(this)
|
||||
queueMicrotask(() => {
|
||||
if (this.readyState !== FakeWebSocket.CONNECTING) return
|
||||
this.readyState = FakeWebSocket.OPEN
|
||||
this.dispatchEvent(new Event('open'))
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.readyState === FakeWebSocket.CLOSED) return
|
||||
this.readyState = FakeWebSocket.CLOSED
|
||||
this.dispatchEvent(new Event('close'))
|
||||
}
|
||||
|
||||
receive(data: unknown): void {
|
||||
this.dispatchEvent(new MessageEvent('message', { data }))
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as Win).location
|
||||
sockets.length = 0
|
||||
if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket
|
||||
else globalThis.WebSocket = originalWebSocket
|
||||
})
|
||||
|
||||
async function mount(): Promise<ConnectionHandle> {
|
||||
@@ -53,7 +93,7 @@ describe('connection client apply', () => {
|
||||
loop.stop() // teardown must not throw; the fixture streams abort quietly
|
||||
})
|
||||
|
||||
it('WebApiClient carries requests over globalThis.fetch', async () => {
|
||||
it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
@@ -65,9 +105,102 @@ describe('connection client apply', () => {
|
||||
try {
|
||||
// Schema rejection is fine — the transport hop is the assertion.
|
||||
await (handle.api as WebApiClient).host.describe({}).catch(() => undefined)
|
||||
await handle.api.respond({
|
||||
type: 'client-response',
|
||||
rpcId: RpcId('response-over-http'),
|
||||
result: { ok: true, value: {} },
|
||||
}).catch(() => undefined)
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
expect(seen.some(u => u.includes('/api/'))).toBe(true)
|
||||
expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true)
|
||||
expect(seen.some(u => u.includes('/api/respond'))).toBe(true)
|
||||
})
|
||||
|
||||
it('opens one WebSocket per downlink, parses frames, and aborts both without using fetch', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'localhost', search: '', origin: 'http://localhost:3080',
|
||||
}
|
||||
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
const fetch = vi.spyOn(globalThis, 'fetch')
|
||||
const client = (await mount()).api as WebApiClient
|
||||
const envelopes: RpcMessage[][] = []
|
||||
client.subscribeEnvelopes((batch) => { envelopes.push([...batch]) })
|
||||
const opened: string[] = []
|
||||
const muxAbort = new AbortController()
|
||||
const hostAbort = new AbortController()
|
||||
const mux = client.events.mux({}, muxAbort.signal, () => { opened.push('mux') })[Symbol.asyncIterator]()
|
||||
const host = client.events.host({}, hostAbort.signal, () => { opened.push('host') })[Symbol.asyncIterator]()
|
||||
const muxFrame = mux.next()
|
||||
const hostFrame = host.next()
|
||||
await vi.waitFor(() => { expect(sockets).toHaveLength(2) })
|
||||
expect(sockets.map(socket => socket.url)).toEqual([
|
||||
'ws://localhost:3080/api/events.mux',
|
||||
'ws://localhost:3080/api/events.host',
|
||||
])
|
||||
await vi.waitFor(() => { expect(opened).toEqual(['mux', 'host']) })
|
||||
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
sockets[0]!.receive(new Uint8Array([1, 2, 3]))
|
||||
sockets[1]!.receive(JSON.stringify({ type: 'server-request', rpcId: 'bad', method: 'host/session-status', payload: {} }))
|
||||
sockets[0]!.receive(JSON.stringify({
|
||||
type: 'server-request',
|
||||
rpcId: 'mux-browser',
|
||||
method: 'session/subscribed',
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-browser', lastSeq: 8 },
|
||||
}))
|
||||
sockets[1]!.receive(JSON.stringify({
|
||||
type: 'server-request',
|
||||
rpcId: 'host-browser',
|
||||
method: 'host/commands-changed',
|
||||
payload: { type: 'host/commands-changed' },
|
||||
}))
|
||||
expect(await muxFrame).toMatchObject({
|
||||
value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } },
|
||||
})
|
||||
expect(await hostFrame).toMatchObject({
|
||||
value: { rpcId: 'host-browser', payload: { type: 'host/commands-changed' } },
|
||||
})
|
||||
expect(errors).toHaveBeenCalledTimes(2)
|
||||
await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) })
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
|
||||
const muxEnd = mux.next()
|
||||
const hostEnd = host.next()
|
||||
muxAbort.abort()
|
||||
hostAbort.abort()
|
||||
await expect(muxEnd).resolves.toMatchObject({ done: true })
|
||||
await expect(hostEnd).resolves.toMatchObject({ done: true })
|
||||
expect(sockets.every(socket => socket.readyState === FakeWebSocket.CLOSED)).toBe(true)
|
||||
errors.mockRestore()
|
||||
fetch.mockRestore()
|
||||
})
|
||||
|
||||
it('maps an HTTPS page origin to a secure WebSocket URL', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'harness.example', search: '', origin: 'https://harness.example',
|
||||
}
|
||||
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
const client = (await mount()).api
|
||||
const abort = new AbortController()
|
||||
const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]()
|
||||
const pending = iterator.next()
|
||||
await vi.waitFor(() => { expect(sockets[0]?.url).toBe('wss://harness.example/api/events.mux') })
|
||||
abort.abort()
|
||||
await expect(pending).resolves.toMatchObject({ done: true })
|
||||
})
|
||||
|
||||
it('closes a WebSocket immediately when its signal was already aborted', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'localhost', search: '', origin: 'http://localhost:3080',
|
||||
}
|
||||
;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
const client = (await mount()).api
|
||||
const abort = new AbortController()
|
||||
abort.abort()
|
||||
const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]()
|
||||
await expect(iterator.next()).resolves.toMatchObject({ done: true })
|
||||
expect(sockets).toHaveLength(1)
|
||||
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { EventEmitter, once } from 'node:events'
|
||||
import { createServer, request as httpRequest } from 'node:http'
|
||||
import { Readable } from 'node:stream'
|
||||
import { PassThrough, Readable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, inject } from '../src/index.ts'
|
||||
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts'
|
||||
|
||||
/** Structural httpServer fake: the plugin only touches register(). */
|
||||
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
|
||||
/** Structural httpServer fake recording both route registries. */
|
||||
function fakeHttpServer(
|
||||
routes: WebRoute[],
|
||||
upgrades: WebUpgradeRoute[],
|
||||
): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
registerUpgrade(route) {
|
||||
upgrades.push(route)
|
||||
return () => { upgrades.splice(upgrades.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
port: 0,
|
||||
}
|
||||
@@ -45,33 +52,67 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b
|
||||
return { response, state }
|
||||
}
|
||||
|
||||
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
|
||||
async function mounted(config?: { trustedHosts?: string[] }): Promise<{
|
||||
routes: WebRoute[]
|
||||
upgrades: WebUpgradeRoute[]
|
||||
dispose: () => Promise<void>
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
const upgrades: WebUpgradeRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
|
||||
await fiber.await()
|
||||
return { routes, dispose: () => fiber.dispose() }
|
||||
return { routes, upgrades, dispose: () => fiber.dispose() }
|
||||
}
|
||||
|
||||
describe('connection node half', () => {
|
||||
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
|
||||
const routes: WebRoute[] = []
|
||||
const upgrades: WebUpgradeRoute[] = []
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, upgrades) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
|
||||
await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
|
||||
expect(routes).toHaveLength(0)
|
||||
expect(upgrades).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('registers the /api prefix route and removes it with the fiber', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
it('registers one HTTP route plus one upgrade route per downlink and removes all three with the fiber', async () => {
|
||||
const { routes, upgrades, dispose } = await mounted()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
expect(upgrades.map(route => route.path)).toEqual([MUX_EVENTS_PATH, HOST_EVENTS_PATH])
|
||||
await dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
expect(upgrades).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('requires WebSocket upgrade for network GETs to either event path', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
for (const path of [MUX_EVENTS_PATH, HOST_EVENTS_PATH]) {
|
||||
const { response, state } = fakeResponse()
|
||||
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }, path), response)
|
||||
expect(state.status).toBe(426)
|
||||
expect(state.body).toBe('upgrade required')
|
||||
}
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('rejects an untrusted WebSocket upgrade before protocol negotiation', async () => {
|
||||
const { upgrades, dispose } = await mounted()
|
||||
const socket = new PassThrough()
|
||||
const chunks: Buffer[] = []
|
||||
socket.on('data', (chunk: Buffer) => { chunks.push(chunk) })
|
||||
const ended = once(socket, 'end')
|
||||
await upgrades[0]!.handler(fakeRequest({
|
||||
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
|
||||
}, MUX_EVENTS_PATH), socket, Buffer.alloc(0))
|
||||
await ended
|
||||
expect(Buffer.concat(chunks).toString()).toContain('HTTP/1.1 403 Forbidden')
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
|
||||
|
||||
308
packages/client/connection/tests/websocket-downlink.spec.ts
Normal file
308
packages/client/connection/tests/websocket-downlink.spec.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { once } from 'node:events'
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WebSocket from 'ws'
|
||||
import type {
|
||||
ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../src/api-path.ts'
|
||||
import { WebSocketDownlinks } from '../src/websocket-downlink.ts'
|
||||
|
||||
type MuxSource = (signal: AbortSignal) => AsyncIterable<RpcRequest<MuxFrame>>
|
||||
type HostSource = (signal: AbortSignal) => AsyncIterable<RpcRequest<HostFrame>>
|
||||
|
||||
const running: (() => Promise<void>)[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(running.splice(0).map(close => close()))
|
||||
})
|
||||
|
||||
function untilAbort(signal: AbortSignal): Promise<void> {
|
||||
if (signal.aborted) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function * idle<F>(signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
|
||||
await untilAbort(signal)
|
||||
}
|
||||
|
||||
function api(mux: MuxSource, host: HostSource): ApiProxy {
|
||||
return {
|
||||
events: {
|
||||
mux: (_request, signal) => mux(signal),
|
||||
host: (_request, signal) => host(signal),
|
||||
},
|
||||
} as ApiProxy
|
||||
}
|
||||
|
||||
async function serve(downlinks: WebSocketDownlinks): Promise<{
|
||||
origin: string
|
||||
close: () => Promise<void>
|
||||
}> {
|
||||
const server = createServer()
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname
|
||||
if (pathname === MUX_EVENTS_PATH) downlinks.handleMux(request, socket, head)
|
||||
else if (pathname === HOST_EVENTS_PATH) downlinks.handleHost(request, socket, head)
|
||||
else socket.destroy()
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const port = (server.address() as AddressInfo).port
|
||||
return {
|
||||
origin: `ws://127.0.0.1:${String(port)}`,
|
||||
close: async () => {
|
||||
await downlinks.close()
|
||||
await new Promise<void>(resolve => server.close(() => { resolve() }))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function read(socket: WebSocket): Promise<ServerRequest> {
|
||||
return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest)
|
||||
}
|
||||
|
||||
async function acceptedSocket(downlinks: WebSocketDownlinks): Promise<WebSocket> {
|
||||
const server = (downlinks as unknown as { server: { clients: Set<WebSocket> } }).server
|
||||
let accepted: WebSocket | undefined
|
||||
await vi.waitFor(() => {
|
||||
accepted = server.clients.values().next().value
|
||||
expect(accepted).toBeDefined()
|
||||
})
|
||||
return accepted as WebSocket
|
||||
}
|
||||
|
||||
describe('WebSocket downlinks', () => {
|
||||
it('carries mux and host over independent downstream sockets and cancels each source on close', async () => {
|
||||
let muxAborted = false
|
||||
let hostAborted = false
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
try {
|
||||
yield {
|
||||
rpcId: RpcId('mux-1'),
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-1' as never, lastSeq: 4 },
|
||||
}
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
muxAborted = true
|
||||
}
|
||||
},
|
||||
async function * (signal) {
|
||||
try {
|
||||
yield { rpcId: RpcId('host-1'), payload: { type: 'host/commands-changed' } }
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
hostAborted = true
|
||||
}
|
||||
},
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
|
||||
const mux = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
const hostSocket = new WebSocket(`${host.origin}${HOST_EVENTS_PATH}`)
|
||||
const muxFrame = read(mux)
|
||||
const hostFrame = read(hostSocket)
|
||||
expect(await muxFrame).toEqual({
|
||||
type: 'server-request',
|
||||
rpcId: 'mux-1',
|
||||
method: 'session/subscribed',
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-1', lastSeq: 4 },
|
||||
})
|
||||
expect(await hostFrame).toEqual({
|
||||
type: 'server-request',
|
||||
rpcId: 'host-1',
|
||||
method: 'host/commands-changed',
|
||||
payload: { type: 'host/commands-changed' },
|
||||
})
|
||||
|
||||
const muxClosed = once(mux, 'close')
|
||||
const hostClosed = once(hostSocket, 'close')
|
||||
mux.close()
|
||||
hostSocket.close()
|
||||
await Promise.all([muxClosed, hostClosed])
|
||||
await vi.waitFor(() => {
|
||||
expect(muxAborted).toBe(true)
|
||||
expect(hostAborted).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects client messages because upstream remains HTTP', async () => {
|
||||
let aborted = false
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
try {
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
aborted = true
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
const closed = once(socket, 'close')
|
||||
socket.send('upstream payload')
|
||||
const [code, reason] = await closed as [number, Buffer]
|
||||
expect(code).toBe(1008)
|
||||
expect(String(reason)).toBe('downlink only')
|
||||
await vi.waitFor(() => { expect(aborted).toBe(true) })
|
||||
})
|
||||
|
||||
it('sends stream/error before closing when a source fails', async () => {
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * () {
|
||||
throw new Error('mux source failed')
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
const failure = read(socket)
|
||||
const closed = once(socket, 'close')
|
||||
expect((await failure).payload).toEqual({
|
||||
type: 'stream/error',
|
||||
error: { code: 'internal', message: 'Error: mux source failed', details: {} },
|
||||
})
|
||||
await closed
|
||||
})
|
||||
|
||||
it('aborts the source when an accepted socket reports a transport error', async () => {
|
||||
let aborted = false
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
try {
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
aborted = true
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
const accepted = await acceptedSocket(downlinks)
|
||||
const closed = once(socket, 'close')
|
||||
accepted.emit('error', new Error('transport failed'))
|
||||
await closed
|
||||
expect(aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('drops a source frame that races after the client has closed', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
let finish!: () => void
|
||||
const finished = new Promise<void>((resolve) => { finish = resolve })
|
||||
let sourceSignal: AbortSignal | undefined
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
sourceSignal = signal
|
||||
try {
|
||||
await gate
|
||||
yield {
|
||||
rpcId: RpcId('late'),
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-late' as never, lastSeq: 0 },
|
||||
}
|
||||
} finally {
|
||||
finish()
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
const closed = once(socket, 'close')
|
||||
socket.close()
|
||||
await closed
|
||||
await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) })
|
||||
release()
|
||||
await finished
|
||||
})
|
||||
|
||||
it('contains socket send callback failures and closes the downlink', async () => {
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((resolve) => { release = resolve })
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * () {
|
||||
await gate
|
||||
yield {
|
||||
rpcId: RpcId('send-failure'),
|
||||
payload: { type: 'session/subscribed', sessionId: 'session-send' as never, lastSeq: 0 },
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
running.push(host.close)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
const accepted = await acceptedSocket(downlinks)
|
||||
const send = vi.spyOn(accepted, 'send').mockImplementation(((
|
||||
_data: unknown,
|
||||
optionsOrCallback?: unknown,
|
||||
callback?: (error?: Error) => void,
|
||||
) => {
|
||||
const done = typeof optionsOrCallback === 'function'
|
||||
? optionsOrCallback as (error?: Error) => void
|
||||
: callback
|
||||
done?.(new Error('socket send failed'))
|
||||
}) as WebSocket['send'])
|
||||
const closed = once(socket, 'close')
|
||||
release()
|
||||
await closed
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
send.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects when its acceptor has already closed', async () => {
|
||||
const downlinks = new WebSocketDownlinks(api(idle, idle))
|
||||
await downlinks.close()
|
||||
await expect(downlinks.close()).rejects.toThrow('The server is not running')
|
||||
})
|
||||
|
||||
it('waits for source cleanup before teardown resolves', async () => {
|
||||
let cleanupStarted!: () => void
|
||||
const started = new Promise<void>((resolve) => { cleanupStarted = resolve })
|
||||
let releaseCleanup!: () => void
|
||||
const cleanupGate = new Promise<void>((resolve) => { releaseCleanup = resolve })
|
||||
let cleaned = false
|
||||
const downlinks = new WebSocketDownlinks(api(
|
||||
async function * (signal) {
|
||||
try {
|
||||
await untilAbort(signal)
|
||||
} finally {
|
||||
cleanupStarted()
|
||||
await cleanupGate
|
||||
cleaned = true
|
||||
}
|
||||
},
|
||||
idle,
|
||||
))
|
||||
const host = await serve(downlinks)
|
||||
const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`)
|
||||
await once(socket, 'open')
|
||||
let closed = false
|
||||
const closing = host.close().then(() => { closed = true })
|
||||
try {
|
||||
await started
|
||||
expect(closed).toBe(false)
|
||||
releaseCleanup()
|
||||
await closing
|
||||
expect(cleaned).toBe(true)
|
||||
} finally {
|
||||
releaseCleanup()
|
||||
await closing
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user