Merge remote-tracking branch 'origin/main'

This commit is contained in:
程序员阿江(Relakkes)
2026-07-23 00:31:40 +08:00
90 changed files with 2963 additions and 687 deletions
+21 -1
View File
@@ -2,6 +2,18 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
import { WsBridge } from '../ws-bridge.js'
import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws'
async function waitFor(
predicate: () => boolean,
timeoutMs = 500,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) return true
await new Promise((resolve) => setTimeout(resolve, 5))
}
return predicate()
}
describe('WsBridge', () => {
let bridge: WsBridge
@@ -58,8 +70,16 @@ describe('WsBridge', () => {
expect(bridge.hasSession('a')).toBe(false)
expect(bridge.hasSession('b')).toBe(false)
await new Promise((resolve) => setTimeout(resolve, 20))
for (const ws of sockets) {
const settled = await waitFor(() => (
ws.readyState === ws.CLOSED
&& ws.listenerCount('open') === 0
&& ws.listenerCount('error') === 0
&& ws.listenerCount('close') === 0
))
expect(settled).toBe(true)
expect(ws.readyState).toBe(ws.CLOSED)
expect(ws.listenerCount('open')).toBe(0)
expect(ws.listenerCount('error')).toBe(0)
expect(ws.listenerCount('close')).toBe(0)
}
+25 -3
View File
@@ -242,9 +242,31 @@ export class WsBridge {
ws.removeAllListeners()
if (ws.readyState === WebSocket.CLOSED) return
// `ws.close()` aborts an in-flight handshake by emitting an asynchronous
// error before close. Keep a temporary sink after detaching the session
// listeners so teardown cannot surface an unhandled EventEmitter error.
if (ws.readyState === WebSocket.CONNECTING) {
// Bun's `ws` compatibility layer can remain stuck in CLOSING when a
// handshake is aborted. Let the handshake settle, consuming its natural
// error, and close normally if the connection opens first.
const cleanup = () => {
ws.removeListener('open', onOpen)
ws.removeListener('error', onError)
ws.removeListener('close', onClose)
}
const onOpen = () => {
ws.removeListener('open', onOpen)
ws.close(code, reason)
}
const onError = () => cleanup()
const onClose = () => cleanup()
ws.once('open', onOpen)
ws.once('error', onError)
ws.once('close', onClose)
return
}
// Keep a temporary error sink after detaching the session listeners so a
// close-time transport error cannot surface as an unhandled EventEmitter
// error.
const swallowTeardownError = () => {}
ws.on('error', swallowTeardownError)
ws.once('close', () => {