fix(web): quiesce websocket teardown

This commit is contained in:
imccyu
2026-08-04 16:42:50 +08:00
parent c6d0cbd8de
commit 7f3a2dae91
17 changed files with 124 additions and 43 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: 11bfc950b7d4f09f1a3075e0f444966de841be70
README.zh.md: 4b346de9e0dbc468d6b94546aa963a5b57b62127
README.md: faf093964a740092983e13bf88f2cccd853c3e36
README.zh.md: b06ab245dedbde13957aa416be044ef107b2753c

View File

@@ -10,7 +10,7 @@ The node half guards every entry under `/api` before bridging or upgrading (`src
## `/api` WebSocket downlinks
`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier.
`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier.
## Keyless fixture

View File

@@ -10,7 +10,7 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r
## `/api` WebSocket 下行
`/api/events.mux``/api/events.host` 各接受一条 WebSocket upgrade并只向浏览器发送对应的 `ServerRequest` text message客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。普通网络 GET 这些路径会返回 426不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。
`/api/events.mux``/api/events.host` 各接受一条 WebSocket upgrade并只向浏览器发送对应的 `ServerRequest` text message客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket open 且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426不保留 SSE 回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。
## 无密钥 fixture

View File

@@ -50,6 +50,7 @@ function failureFrame(error: unknown): RpcRequest<Frame> {
*/
export class WebSocketDownlinks {
private readonly server = new WebSocketServer({ noServer: true })
private readonly pumps = new Set<Promise<void>>()
/** @param api - host API supplying the typed event streams. */
constructor(private readonly api: ApiProxy) {}
@@ -81,17 +82,18 @@ export class WebSocketDownlinks {
}
/**
* Terminate owned sockets and await the no-server acceptor's close.
* @returns A promise resolving after every accepted socket has closed.
* Terminate owned sockets and await the no-server acceptor plus frame pumps.
* @returns A promise resolving after every socket and source iterator stops.
*/
close(): Promise<void> {
async close(): Promise<void> {
for (const socket of this.server.clients) socket.terminate()
return new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
this.server.close((error) => {
if (error === undefined) resolve()
else reject(error)
})
})
await Promise.all(this.pumps)
}
private upgrade<F extends Frame>(
@@ -107,7 +109,9 @@ export class WebSocketDownlinks {
websocket.once('message', () => {
websocket.close(1008, 'downlink only')
})
void this.pump(websocket, open(abort.signal), abort)
const pump = this.pump(websocket, open(abort.signal), abort)
this.pumps.add(pump)
void pump.then(() => { this.pumps.delete(pump) })
})
}

View File

@@ -21,7 +21,9 @@ afterEach(async () => {
function untilAbort(signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.resolve()
return new Promise(resolve => signal.addEventListener('abort', () => { resolve() }, { once: true }))
return new Promise((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
}
async function * idle<F>(signal: AbortSignal): AsyncGenerator<RpcRequest<F>> {
@@ -147,7 +149,7 @@ describe('WebSocket downlinks', () => {
await once(socket, 'open')
const closed = once(socket, 'close')
socket.send('upstream payload')
const [code, reason] = await closed
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) })
@@ -197,9 +199,9 @@ describe('WebSocket downlinks', () => {
it('drops a source frame that races after the client has closed', async () => {
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
const gate = new Promise<void>((resolve) => { release = resolve })
let finish!: () => void
const finished = new Promise<void>(resolve => { finish = resolve })
const finished = new Promise<void>((resolve) => { finish = resolve })
let sourceSignal: AbortSignal | undefined
const downlinks = new WebSocketDownlinks(api(
async function * (signal) {
@@ -227,7 +229,7 @@ describe('WebSocket downlinks', () => {
it('contains socket send callback failures and closes the downlink', async () => {
let release!: () => void
const gate = new Promise<void>(resolve => { release = resolve })
const gate = new Promise<void>((resolve) => { release = resolve })
const downlinks = new WebSocketDownlinks(api(
async function * () {
await gate
@@ -262,4 +264,39 @@ describe('WebSocket downlinks', () => {
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
}
})
})