From 65bf19a3450dbbd8e6ab95adfef1463edc7206ba Mon Sep 17 00:00:00 2001 From: HuangLeijiana <2644901977@qq.com> Date: Sun, 6 Sep 2026 00:13:23 +0800 Subject: [PATCH] fix(mobile): keep a permanent ws error sink in streamSession Closing a still-CONNECTING mux WebSocket from finish() makes ws raise 'error' asynchronously, after cleanup() has removed the real listener. consumeMux guards that self-inflicted error with a permanent empty sink and documents it as a process crash without one; streamSession unwinds with the identical close pattern but had no sink, so aborting a session stream (SSE disconnect, bridge stop) while the gateway handshake is in flight could take the whole Electron main process down. Add the same permanent sink with a lockstep comment, plus an integration test that holds the mux upgrade open and aborts the SSE request: without the sink the run dies on an unhandled 'error' at the close site. --- src/main/mobile/lan-mobile-bridge.ts | 8 ++++++ test/lan-mobile-bridge.test.ts | 40 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/main/mobile/lan-mobile-bridge.ts b/src/main/mobile/lan-mobile-bridge.ts index 5b455a498..cdc8b7f69 100644 --- a/src/main/mobile/lan-mobile-bridge.ts +++ b/src/main/mobile/lan-mobile-bridge.ts @@ -1114,6 +1114,14 @@ export class LanMobileBridge { await new Promise((resolve) => { const socket = new WebSocket(url, { headers: cookie === undefined ? {} : { cookie } }) + // `ws` raises 'error' asynchronously when close() aborts a still-CONNECTING + // socket ("WebSocket was closed before the connection was established") — + // exactly what finish() below does to unwind on abort. cleanup() has + // already removed the real handleError listener by the time that fires, + // so without a permanent sink here that self-inflicted error becomes an + // unhandled 'error' event and crashes the process. consumeMux guards the + // same close pattern with the identical sink; keep the two in lockstep. + socket.addEventListener('error', () => {}) let settled = false const cleanup = (): void => { request.removeListener('close', finish) diff --git a/test/lan-mobile-bridge.test.ts b/test/lan-mobile-bridge.test.ts index 53c701c00..0ff951a52 100644 --- a/test/lan-mobile-bridge.test.ts +++ b/test/lan-mobile-bridge.test.ts @@ -658,6 +658,46 @@ describe('LAN mobile bridge pairing surface', () => { } }) }) + + it('does not crash when a session stream is aborted while the mux handshake is still connecting', async () => { + // Regression guard for the permanent ws 'error' sink in streamSession. + // Aborting the SSE request makes streamSession close its upstream mux + // WebSocket; when the handshake never completed, `ws` then raises 'error' + // asynchronously ("closed before the connection was established") after + // cleanup() has removed the real listener. Without a permanent sink that + // self-inflicted error becomes an unhandled 'error' event and takes the + // whole process down (consumeMux documents the identical crash path). + const upgraded: import('node:net').Socket[] = [] + const harness = createServer((_request, response) => { + response.statusCode = 404 + response.end() + }) + // Swallow the upgrade: the mux socket stays CONNECTING on the bridge side. + harness.on('upgrade', (request, socket) => { + if (request.url !== '/api/remote.mux') return socket.destroy() + upgraded.push(socket) + }) + servers.push(harness) + await new Promise((resolve) => harness.listen(0, '127.0.0.1', resolve)) + const harnessPort = (harness.address() as AddressInfo).port + const bridge = new LanMobileBridge({ + harnessUrl: () => `http://127.0.0.1:${harnessPort}` + }) + bridges.push(bridge) + const { port, cookie } = await pairBridge(bridge) + const abort = new AbortController() + const response = await fetch( + `http://127.0.0.1:${port}/api/session/stream?sessionId=session-1`, + { headers: { cookie }, signal: abort.signal } + ) + expect(response.status).toBe(200) + abort.abort() + // Let the async 'error' (when present) fire; with the sink in place the + // process survives and the request unwinds cleanly. + await new Promise((resolve) => setTimeout(resolve, 100)) + for (const socket of upgraded) socket.destroy() + expect(true).toBe(true) + }) }) describe('LAN mobile bridge user questions', () => {