fix(desktop): harden Windows crash recovery #1064 #1065 #1071

This commit is contained in:
程序员阿江(Relakkes)
2026-07-20 22:06:15 +08:00
parent ba8917f1db
commit 727bcea077
18 changed files with 759 additions and 67 deletions
+16 -1
View File
@@ -49,12 +49,20 @@ describe('WsBridge', () => {
expect(bridge.sendStopGeneration('chat-stop')).toBe(false)
})
it('destroy cleans up all sessions', () => {
it('destroy cleans up all sessions without leaking connecting-socket errors', async () => {
bridge.connectSession('a', 'uuid-a')
bridge.connectSession('b', 'uuid-b')
const sockets = [...(bridge as any).sessions.values()]
.map((session: any) => session.ws)
bridge.destroy()
expect(bridge.hasSession('a')).toBe(false)
expect(bridge.hasSession('b')).toBe(false)
await new Promise((resolve) => setTimeout(resolve, 20))
for (const ws of sockets) {
expect(ws.listenerCount('error')).toBe(0)
expect(ws.listenerCount('close')).toBe(0)
}
})
})
@@ -225,6 +233,13 @@ describe('WsBridge: handler serialization', () => {
bridge.resetSession('chat-reset')
expect(bridge.hasSession('chat-reset')).toBe(false)
expect(staleSession.ws.listenerCount('message')).toBe(0)
await new Promise<void>((resolve) => {
if (staleSession.ws.readyState === staleSession.ws.CLOSED) {
resolve()
return
}
staleSession.ws.once('close', () => resolve())
})
expect(staleSession.ws.listenerCount('close')).toBe(0)
expect(staleSession.ws.listenerCount('error')).toBe(0)
+18 -5
View File
@@ -135,8 +135,7 @@ export class WsBridge {
const session = this.sessions.get(chatId)
if (session) {
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
session.ws.removeAllListeners()
session.ws.close(1000, 'session reset')
this.closeSocket(session.ws, 1000, 'session reset')
this.sessions.delete(chatId)
}
this.handlers.delete(chatId)
@@ -157,8 +156,7 @@ export class WsBridge {
}
for (const [, session] of this.sessions) {
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
session.ws.removeAllListeners()
session.ws.close(1000, 'bridge destroyed')
this.closeSocket(session.ws, 1000, 'bridge destroyed')
}
this.sessions.clear()
this.handlers.clear()
@@ -178,7 +176,7 @@ export class WsBridge {
const prev = this.sessions.get(chatId)
if (prev) {
if (prev.reconnectTimer) clearTimeout(prev.reconnectTimer)
prev.ws.removeAllListeners()
this.closeSocket(prev.ws, 1000, 'session replaced')
}
const session: Session = {
@@ -240,6 +238,21 @@ export class WsBridge {
})
}
private closeSocket(ws: WebSocket, code: number, reason: string): void {
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.
const swallowTeardownError = () => {}
ws.on('error', swallowTeardownError)
ws.once('close', () => {
ws.removeListener('error', swallowTeardownError)
})
ws.close(code, reason)
}
/** Wait until the WebSocket for chatId is open. Resolves false on timeout or error. */
waitForOpen(chatId: string, timeoutMs = 10_000): Promise<boolean> {
const session = this.sessions.get(chatId)