Skip to content

Commit cbc0bc4

Browse files
committed
🤖 fix: make exec stdin.close idempotent for remote runtimes
CI integration tests showed ssh exec streams could return a stdin WritableStream that fails on close for short-lived commands. Wrap child stdin with a small WritableStream facade so close() is safe even if the underlying stream is already ended.
1 parent a1d6e9a commit cbc0bc4

File tree

1 file changed

+45
-2
lines changed

1 file changed

+45
-2
lines changed

src/node/runtime/RemoteRuntime.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616

1717
import type { ChildProcess } from "child_process";
18-
import { Readable, Writable } from "stream";
18+
import { Readable } from "stream";
1919
import type {
2020
Runtime,
2121
ExecOptions,
@@ -145,7 +145,50 @@ export abstract class RemoteRuntime implements Runtime {
145145
// Convert Node.js streams to Web Streams
146146
const stdout = Readable.toWeb(childProcess.stdout!) as unknown as ReadableStream<Uint8Array>;
147147
const stderr = Readable.toWeb(childProcess.stderr!) as unknown as ReadableStream<Uint8Array>;
148-
const stdin = Writable.toWeb(childProcess.stdin!) as unknown as WritableStream<Uint8Array>;
148+
149+
// Writable.toWeb(childProcess.stdin) is surprisingly easy to get into an invalid state
150+
// for short-lived remote commands (notably via SSH) where stdin may already be closed
151+
// by the time callers attempt `await stream.stdin.close()`.
152+
//
153+
// Wrap stdin ourselves so close() is idempotent.
154+
const stdin = new WritableStream<Uint8Array>({
155+
write: async (chunk) => {
156+
const nodeStdin = childProcess.stdin;
157+
if (!nodeStdin || nodeStdin.destroyed) {
158+
return;
159+
}
160+
161+
await new Promise<void>((resolve, reject) => {
162+
const onError = (err: Error) => {
163+
nodeStdin.off("error", onError);
164+
reject(err);
165+
};
166+
nodeStdin.on("error", onError);
167+
168+
nodeStdin.write(Buffer.from(chunk), (err) => {
169+
nodeStdin.off("error", onError);
170+
if (err) {
171+
reject(err);
172+
return;
173+
}
174+
resolve();
175+
});
176+
});
177+
},
178+
close: async () => {
179+
const nodeStdin = childProcess.stdin;
180+
if (!nodeStdin || nodeStdin.destroyed || nodeStdin.writableEnded) {
181+
return;
182+
}
183+
184+
await new Promise<void>((resolve) => {
185+
nodeStdin.end(() => resolve());
186+
});
187+
},
188+
abort: () => {
189+
childProcess.stdin?.destroy();
190+
},
191+
});
149192

150193
// Track if we killed the process due to timeout or abort
151194
let timedOut = false;

0 commit comments

Comments
 (0)