fix(process): keep secret pipe errors handled (#112550)

This commit is contained in:
Peter Steinberger
2026-07-21 23:59:19 -07:00
committed by GitHub
parent 344feae7a1
commit 3ed2a144ac
2 changed files with 80 additions and 6 deletions
+61
View File
@@ -0,0 +1,61 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { describe, expect, it } from "vitest";
import { writeSecretInputToChild } from "./spawn-secret-input.js";
class ControlledSecretStream extends EventEmitter {
private endCallback: ((error?: Error | null) => void) | undefined;
end(_data: Buffer, callback?: (error?: Error | null) => void): this {
this.endCallback = callback;
return this;
}
finishWrite(error?: Error): void {
if (!this.endCallback) {
throw new Error("secret write callback was not registered");
}
this.endCallback(error);
}
}
function childWithSecretStream(stream: ControlledSecretStream): ChildProcess {
return { stdio: [null, null, null, stream] } as unknown as ChildProcess;
}
function writeSecret(stream: ControlledSecretStream): Promise<void> {
return writeSecretInputToChild(childWithSecretStream(stream), {
fd: 3,
createData: () => Buffer.from("selected-secret"),
});
}
describe("writeSecretInputToChild", () => {
it("consumes pipe errors after delivery until the stream closes", async () => {
const stream = new ControlledSecretStream();
const write = writeSecret(stream);
stream.finishWrite();
await expect(write).resolves.toBeUndefined();
const reset = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" });
expect(() => stream.emit("error", reset)).not.toThrow();
expect(stream.listenerCount("error")).toBe(1);
stream.emit("close");
expect(stream.listenerCount("error")).toBe(0);
});
it("rejects delivery errors before consuming their later stream event", async () => {
const stream = new ControlledSecretStream();
const write = writeSecret(stream);
const deliveryError = new Error("secret delivery failed");
stream.finishWrite(deliveryError);
await expect(write).rejects.toBe(deliveryError);
expect(() => stream.emit("error", deliveryError)).not.toThrow();
stream.emit("close");
expect(stream.listenerCount("error")).toBe(0);
});
});
+19 -6
View File
@@ -37,15 +37,28 @@ export async function writeSecretInputToChild(
// End the parent pipe immediately after delivery so descendants cannot
// inherit a still-readable credential stream.
await new Promise<void>((resolve, reject) => {
const onError = (error: Error) => {
stream.off("error", onError);
reject(error);
let settled = false;
const settle = (error?: Error | null) => {
if (settled) {
return;
}
settled = true;
if (error) {
reject(error);
} else {
resolve();
}
};
stream.once("error", onError);
stream.end(data, () => {
const onError = (error: Error) => {
settle(error);
};
// A child-process pipe can emit its terminal error after end's callback.
// Keep it handled until close while only the first outcome settles delivery.
stream.on("error", onError);
stream.once("close", () => {
stream.off("error", onError);
resolve();
});
stream.end(data, settle);
});
} finally {
data?.fill(0);