diff --git a/src/infra/ssh-tunnel.test.ts b/src/infra/ssh-tunnel.test.ts index 608be32858b6..9a16d09941f3 100644 --- a/src/infra/ssh-tunnel.test.ts +++ b/src/infra/ssh-tunnel.test.ts @@ -205,4 +205,29 @@ describe("startSshPortForward", () => { await rejection; expect(kill).toHaveBeenCalledWith("SIGTERM"); }); + + it.each(["active", "teardown"] as const)( + "does not crash when stderr errors while the tunnel is %s", + async (phase) => { + vi.useFakeTimers(); + spawnFakeSshListening(); + + const tunnel = await startSshPortForward({ + target: "me@example.com:2222", + localPortPreferred: 43210, + remotePort: 18789, + timeoutMs: 1000, + }); + + const child = mocks.spawn.mock.results[0]?.value as EventEmitter & { + killed: boolean; + stderr: EventEmitter; + }; + const stopping = phase === "teardown" ? tunnel.stop() : undefined; + expect(child.killed).toBe(phase === "teardown"); + expect(() => child.stderr.emit("error", new Error("stderr EPIPE"))).not.toThrow(); + + await expect(stopping ?? tunnel.stop()).resolves.toBeUndefined(); + }, + ); }); diff --git a/src/infra/ssh-tunnel.ts b/src/infra/ssh-tunnel.ts index 94d973e642e0..d9ed2908cee0 100644 --- a/src/infra/ssh-tunnel.ts +++ b/src/infra/ssh-tunnel.ts @@ -165,8 +165,12 @@ export async function startSshPortForward(opts: { const child = spawn("/usr/bin/ssh", args, { stdio: ["ignore", "ignore", "pipe"], }); - child.stderr?.setEncoding("utf8"); - child.stderr?.on("data", (chunk) => { + const stderrStream = child.stderr; + // Child events own tunnel failure. Keep the diagnostic pipe observed so a + // stream error cannot become an uncaught exception during active use or teardown. + stderrStream?.on("error", () => {}); + stderrStream?.setEncoding("utf8"); + stderrStream?.on("data", (chunk) => { const lines = normalizeStringEntries(String(chunk).split("\n")); stderr.push(...lines); });