Skip to content

Commit 0ee845a

Browse files
committed
fix(integration): retry transient EBADF subprocess failures
1 parent fdf51b0 commit 0ee845a

2 files changed

Lines changed: 27 additions & 1 deletion

File tree

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import { describe, expect, test } from "bun:test";
2-
import { run } from "./process";
2+
import { DEFAULT_LOCAL_PROCESS_ATTEMPTS, isRetryableLocalProcessFailure, run } from "./process";
33

44
describe("integration process helpers", () => {
55
test("renders stdout and stderr for failed commands", async () => {
66
await expect(run(["bash", "-lc", "echo useful-stdout; echo useful-stderr >&2; exit 7"])).rejects.toThrow(
77
/command failed \(7\): bash -lc.*stdout:\nuseful-stdout.*stderr:\nuseful-stderr/s
88
);
99
});
10+
11+
test("retries only local EBADF subprocess failures", () => {
12+
expect(DEFAULT_LOCAL_PROCESS_ATTEMPTS).toBeGreaterThan(1);
13+
expect(isRetryableLocalProcessFailure({ exitCode: 127, stdout: "", stderr: "EBADF: bad file descriptor, epoll_ctl" })).toBeTrue();
14+
expect(isRetryableLocalProcessFailure({ exitCode: 127, stdout: "", stderr: "bash: missing-command: command not found" })).toBeFalse();
15+
expect(isRetryableLocalProcessFailure({ exitCode: 124, stdout: "", stderr: "EBADF: bad file descriptor, epoll_ctl" })).toBeFalse();
16+
});
1017
});

tests/integration/lib/process.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export type CommandResult = {
1313
stderr: string;
1414
};
1515

16+
export const DEFAULT_LOCAL_PROCESS_ATTEMPTS = 3;
17+
1618
function renderCommandFailure(cmd: string[], result: CommandResult): string {
1719
const parts = [`command failed (${result.exitCode}): ${cmd.join(" ")}`];
1820
const stdout = result.stdout.trim();
@@ -26,13 +28,30 @@ function renderCommandFailure(cmd: string[], result: CommandResult): string {
2628
return parts.join("\n\n");
2729
}
2830

31+
export function isRetryableLocalProcessFailure(result: CommandResult): boolean {
32+
const output = `${result.stderr}\n${result.stdout}`;
33+
return result.exitCode === 127 && /\bEBADF\b/.test(output) && /epoll_ctl/.test(output);
34+
}
35+
2936
/**
3037
* Runs a command as argv, captures stdout/stderr, and never throws on non-zero exit.
3138
*
3239
* The harness uses this as the lowest-level primitive so every provider,
3340
* remote helper, and assertion can decide whether a failure is expected or fatal.
3441
*/
3542
export async function runAllowFailure(cmd: string[], options: CommandOptions = {}): Promise<CommandResult> {
43+
let result: CommandResult = { exitCode: 127, stdout: "", stderr: "command was not attempted" };
44+
for (let attempt = 1; attempt <= DEFAULT_LOCAL_PROCESS_ATTEMPTS; attempt += 1) {
45+
result = await runOnceAllowFailure(cmd, options);
46+
if (!isRetryableLocalProcessFailure(result) || attempt >= DEFAULT_LOCAL_PROCESS_ATTEMPTS) {
47+
return result;
48+
}
49+
await Bun.sleep(attempt * 500);
50+
}
51+
return result;
52+
}
53+
54+
async function runOnceAllowFailure(cmd: string[], options: CommandOptions = {}): Promise<CommandResult> {
3655
return await new Promise<CommandResult>((resolve) => {
3756
const stdout: Buffer[] = [];
3857
const stderr: Buffer[] = [];

0 commit comments

Comments
 (0)