Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions plugins/codex/scripts/lib/fs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,54 @@ export function isProbablyText(buffer) {
return true;
}

export function readStdinIfPiped() {
if (process.stdin.isTTY) {
const stdinRetryWaitArray = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
const STDIN_CHUNK_SIZE = 64 * 1024;

function waitForStdinRetry(delayMs) {
Atomics.wait(stdinRetryWaitArray, 0, 0, delayMs);
}

function isTransientReadError(error) {
return error?.code === "EAGAIN" || error?.code === "EWOULDBLOCK";
}

export function readStdinIfPiped({
stdin = process.stdin,
readSync = fs.readSync,
waitForRetry = waitForStdinRetry,
maxAttempts = 8,
initialRetryDelayMs = 10,
maxRetryDelayMs = 500,
chunkSize = STDIN_CHUNK_SIZE
} = {}) {
if (stdin.isTTY) {
return "";
}
return fs.readFileSync(0, "utf8");

try {
Reflect.get(stdin, "_handle")?.setBlocking?.(true);
} catch {
// Some stdin handle types do not support changing blocking mode.
}

const chunks = [];
let transientAttempts = 0;
let retryDelayMs = initialRetryDelayMs;
while (true) {
const chunk = Buffer.allocUnsafe(chunkSize);
try {
const bytesRead = readSync(0, chunk, 0, chunk.length, null);
if (bytesRead === 0) {
return Buffer.concat(chunks).toString("utf8");
}
chunks.push(chunk.subarray(0, bytesRead));
} catch (error) {
transientAttempts += 1;
if (!isTransientReadError(error) || transientAttempts >= maxAttempts) {
throw error;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep retrying while a pipe remains open

When setBlocking(true) is unavailable and a producer leaves its pipe open without supplying the next chunk for about 1.13 seconds, the eighth EAGAIN is thrown even though it is neither EOF nor an invalid descriptor. For example, a delayed or intermittently streaming piped prompt will fail instead of being read in full; the normal blocking-read behavior has no equivalent timeout. Continue waiting until data or EOF arrives (or expose an explicit caller timeout) rather than treating a fixed retry count as a read failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in af3e94b. Transient EAGAIN/EWOULDBLOCK no longer has an attempt ceiling while the pipe remains open; the loop waits with capped backoff until data or EOF, and still throws non-transient errors immediately. Added a 20-consecutive-transient regression before eventual progress.

}
waitForRetry(retryDelayMs);
retryDelayMs = Math.min(retryDelayMs * 2, maxRetryDelayMs);
}
}
}
77 changes: 77 additions & 0 deletions tests/fs.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";

import { readStdinIfPiped } from "../plugins/codex/scripts/lib/fs.mjs";

function scriptedRead(steps, events = []) {
return (fd, buffer, offset, length, position) => {
events.push(["read", fd, offset, length, position]);
const step = steps.shift();
if (step instanceof Error) throw step;
if (step == null) return 0;
const bytes = Buffer.from(step);
bytes.copy(buffer, offset);
return bytes.length;
};
}

function transient(code = "EAGAIN") {
return Object.assign(new Error("resource temporarily unavailable"), { code });
}

test("readStdinIfPiped preserves bytes consumed before a transient read failure", () => {
const events = [];
const input = readStdinIfPiped({
stdin: { isTTY: false, _handle: { setBlocking: (value) => events.push(["blocking", value]) } },
readSync: scriptedRead(["first-", transient(), "second\n", null], events),
waitForRetry: (delay) => events.push(["wait", delay]),
chunkSize: 32
});
assert.equal(input, "first-second\n");
assert.deepEqual(events[0], ["blocking", true]);
assert.deepEqual(events.filter((event) => event[0] === "wait"), [["wait", 10]]);
});

test("readStdinIfPiped treats EWOULDBLOCK as transient and bounds backoff", () => {
const error = transient("EWOULDBLOCK");
const waits = [];
assert.throws(() => readStdinIfPiped({
stdin: { isTTY: false },
readSync() { throw error; },
waitForRetry: (delay) => waits.push(delay),
maxAttempts: 5,
initialRetryDelayMs: 10,
maxRetryDelayMs: 25
}), (actual) => actual === error);
assert.deepEqual(waits, [10, 20, 25, 25]);
});

test("readStdinIfPiped surfaces non-transient read errors without retrying", () => {
const error = Object.assign(new Error("bad descriptor"), { code: "EBADF" });
let waits = 0;
assert.throws(() => readStdinIfPiped({
stdin: { isTTY: false },
readSync() { throw error; },
waitForRetry() { waits += 1; }
}), (actual) => actual === error);
assert.equal(waits, 0);
});

test("readStdinIfPiped ignores unsupported blocking mode and reads all chunks", () => {
const input = readStdinIfPiped({
stdin: { isTTY: false, _handle: { setBlocking() { throw new Error("unsupported"); } } },
readSync: scriptedRead(["ordinary ", "pipe", null]),
waitForRetry() { throw new Error("unexpected retry"); }
});
assert.equal(input, "ordinary pipe");
});

test("readStdinIfPiped returns empty input for a TTY without touching fd 0", () => {
let reads = 0;
const input = readStdinIfPiped({
stdin: { isTTY: true },
readSync() { reads += 1; return 0; }
});
assert.equal(input, "");
assert.equal(reads, 0);
});