From db8305e393e91f468655851c7aaf25079e675853 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 7 Oct 2026 16:50:07 -0500 Subject: [PATCH] fix: keep client tunnels open when one of their connections fails (Support#1417) --- electron/main.cjs | 25 ++++++------ .../tests/electron/c2s-runtime.test.ts | 38 ++++++++++++++++++- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/electron/main.cjs b/electron/main.cjs index 26ab6d422..a42a28cce 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -2187,11 +2187,6 @@ async function openC2SRelay( targetHost, targetPort, }); - setC2STunnelStatus(tunnelName, { - connected: false, - status: "CONNECTING", - reason: `Opening relay to ${targetHost}:${targetPort}`, - }); const ws = new WebSocket( relayUrl, getWebSocketOptions(relayUrl, { headers }), @@ -2226,13 +2221,16 @@ async function openC2SRelay( socket.on("data", sendChunk); socket.on("close", cleanup); + // One connection failing must not stop the tunnel: the listener refuses + // new connections while the tunnel is not connected, and a browser opens + // many at once. socket.on("error", (error) => { - setC2STunnelError(tunnelName, error.message || "Local socket error"); + logToFile(`[c2s] ${tunnelName} local socket error:`, error.message); cleanup(); }); ws.on("close", cleanup); ws.on("error", (error) => { - setC2STunnelError(tunnelName, error.message || "Relay connection failed"); + logToFile(`[c2s] ${tunnelName} relay error:`, error.message); cleanup(); }); @@ -2259,10 +2257,6 @@ async function openC2SRelay( if (message.type === "ready") { ready = true; logToFile(`[c2s] relay ready for ${tunnelName}`); - setC2STunnelStatus(tunnelName, { - connected: true, - status: "CONNECTED", - }); if (initialData?.length) { ws.send(initialData); } @@ -2275,12 +2269,13 @@ async function openC2SRelay( "Relay rejected the client tunnel", ); logToFile("[c2s] relay error:", relayError); - setC2STunnelError(tunnelName, relayError); + if (relayError === C2S_REMOTE_SESSION_EXPIRED_ERROR) { + setC2STunnelError(tunnelName, relayError); + } cleanup(); } } catch (error) { logToFile("[c2s] invalid relay message:", error.message); - setC2STunnelError(tunnelName, error.message || "Invalid relay response"); cleanup(); } }); @@ -2506,7 +2501,9 @@ function handleC2SLocalConnection(tunnel, socket, authToken) { "Local relay failed", ); logToFile("[c2s] local relay failed:", message); - setC2STunnelError(tunnelName, message); + if (message === C2S_REMOTE_SESSION_EXPIRED_ERROR) { + setC2STunnelError(tunnelName, message); + } socket.destroy(); }); } diff --git a/src/backend/tests/electron/c2s-runtime.test.ts b/src/backend/tests/electron/c2s-runtime.test.ts index 8db0a24e4..734eed276 100644 --- a/src/backend/tests/electron/c2s-runtime.test.ts +++ b/src/backend/tests/electron/c2s-runtime.test.ts @@ -72,9 +72,12 @@ function setup() { runtimes.get(name).status = { connected: false, reason: error }; }, createC2SFailure: (error: string) => ({ success: false, error }), + normalizeC2SErrorMessage: (message: string) => message, + C2S_REMOTE_SESSION_EXPIRED_ERROR: "expired", }); vm.runInContext( - section("async function testC2SRelay(", "async function testC2STunnel(") + + section("async function openC2SRelay(", "async function testC2SRelay(") + + section("async function testC2SRelay(", "async function testC2STunnel(") + section("async function startC2STunnel(", "function stopAllC2STunnels("), context, ); @@ -140,4 +143,37 @@ describe("client tunnel runtime authentication", () => { ).toEqual({ success: false, error: "Tunnel stopped" }); expect(sockets).toHaveLength(1); }); + + it("keeps the tunnel connected when one of its connections fails", async () => { + const { context, runtimes, sockets } = setup(); + runtimes.set("web", { status: { connected: true, status: "CONNECTED" } }); + const local = () => + Object.assign(new EventEmitter(), { + destroy: vi.fn(), + write: vi.fn(), + }); + + const first = local(); + await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, first); + await vi.waitFor(() => expect(sockets).toHaveLength(1)); + first.emit("error", new Error("ECONNRESET")); + sockets[0].emit( + "message", + Buffer.from(JSON.stringify({ type: "error", error: "refused" })), + false, + ); + expect(runtimes.get("web").status.connected).toBe(true); + + await context.openC2SRelay({ name: "web" }, "127.0.0.1", 8006, local()); + await vi.waitFor(() => expect(sockets).toHaveLength(2)); + sockets[1].emit( + "message", + Buffer.from(JSON.stringify({ type: "error", error: "expired" })), + false, + ); + expect(runtimes.get("web").status).toMatchObject({ + connected: false, + reason: "expired", + }); + }); });