Skip to content

Commit cafce4e

Browse files
committed
test(ssh): cover active stream errors
1 parent 9547817 commit cafce4e

2 files changed

Lines changed: 27 additions & 19 deletions

File tree

src/infra/ssh-tunnel.test.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -206,22 +206,28 @@ describe("startSshPortForward", () => {
206206
expect(kill).toHaveBeenCalledWith("SIGTERM");
207207
});
208208

209-
it("does not crash when stderr emits a stream error", async () => {
210-
vi.useFakeTimers();
211-
spawnFakeSshListening();
209+
it.each(["active", "teardown"] as const)(
210+
"does not crash when stderr errors while the tunnel is %s",
211+
async (phase) => {
212+
vi.useFakeTimers();
213+
spawnFakeSshListening();
212214

213-
const tunnel = await startSshPortForward({
214-
target: "me@example.com:2222",
215-
localPortPreferred: 43210,
216-
remotePort: 18789,
217-
timeoutMs: 1000,
218-
});
215+
const tunnel = await startSshPortForward({
216+
target: "me@example.com:2222",
217+
localPortPreferred: 43210,
218+
remotePort: 18789,
219+
timeoutMs: 1000,
220+
});
219221

220-
const child = mocks.spawn.mock.results[0]?.value as EventEmitter & {
221-
stderr: EventEmitter;
222-
};
223-
child.stderr.emit("error", new Error("stderr EPIPE"));
222+
const child = mocks.spawn.mock.results[0]?.value as EventEmitter & {
223+
killed: boolean;
224+
stderr: EventEmitter;
225+
};
226+
const stopping = phase === "teardown" ? tunnel.stop() : undefined;
227+
expect(child.killed).toBe(phase === "teardown");
228+
expect(() => child.stderr.emit("error", new Error("stderr EPIPE"))).not.toThrow();
224229

225-
await expect(tunnel.stop()).resolves.toBeUndefined();
226-
});
230+
await expect(stopping ?? tunnel.stop()).resolves.toBeUndefined();
231+
},
232+
);
227233
});

src/infra/ssh-tunnel.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,15 @@ export async function startSshPortForward(opts: {
165165
const child = spawn("/usr/bin/ssh", args, {
166166
stdio: ["ignore", "ignore", "pipe"],
167167
});
168-
child.stderr?.setEncoding("utf8");
169-
child.stderr?.on("data", (chunk) => {
168+
const stderrStream = child.stderr;
169+
// Child events own tunnel failure. Keep the diagnostic pipe observed so a
170+
// stream error cannot become an uncaught exception during active use or teardown.
171+
stderrStream?.on("error", () => {});
172+
stderrStream?.setEncoding("utf8");
173+
stderrStream?.on("data", (chunk) => {
170174
const lines = normalizeStringEntries(String(chunk).split("\n"));
171175
stderr.push(...lines);
172176
});
173-
// The diagnostic pipe can fail independently; child exit remains authoritative.
174-
child.stderr?.on("error", () => {});
175177

176178
const stop = async () => {
177179
if (child.killed || !child.kill("SIGTERM")) {

0 commit comments

Comments
 (0)