| import type { ChildProcess } from "node:child_process"; |
| import { EventEmitter } from "node:events"; |
| import { PassThrough } from "node:stream"; |
| import { describe, expect, it, vi } from "vitest"; |
| import { createRestartIterationHook } from "./restart-recovery.js"; |
| import { spawnWithFallback } from "./spawn-utils.js"; |
|
|
| function createStubChild() { |
| const child = new EventEmitter() as ChildProcess; |
| child.stdin = new PassThrough() as ChildProcess["stdin"]; |
| child.stdout = new PassThrough() as ChildProcess["stdout"]; |
| child.stderr = new PassThrough() as ChildProcess["stderr"]; |
| Object.defineProperty(child, "pid", { value: 1234, configurable: true }); |
| Object.defineProperty(child, "killed", { value: false, configurable: true, writable: true }); |
| child.kill = vi.fn(() => true) as ChildProcess["kill"]; |
| queueMicrotask(() => { |
| child.emit("spawn"); |
| }); |
| return child; |
| } |
|
|
| describe("spawnWithFallback", () => { |
| it("retries on EBADF using fallback options", async () => { |
| const spawnMock = vi |
| .fn() |
| .mockImplementationOnce(() => { |
| const err = new Error("spawn EBADF"); |
| (err as NodeJS.ErrnoException).code = "EBADF"; |
| throw err; |
| }) |
| .mockImplementationOnce(() => createStubChild()); |
|
|
| const result = await spawnWithFallback({ |
| argv: ["echo", "ok"], |
| options: { stdio: ["pipe", "pipe", "pipe"] }, |
| fallbacks: [{ label: "safe-stdin", options: { stdio: ["ignore", "pipe", "pipe"] } }], |
| spawnImpl: spawnMock, |
| }); |
|
|
| expect(result.usedFallback).toBe(true); |
| expect(result.fallbackLabel).toBe("safe-stdin"); |
| expect(spawnMock).toHaveBeenCalledTimes(2); |
| expect(spawnMock.mock.calls[0]?.[2]?.stdio).toEqual(["pipe", "pipe", "pipe"]); |
| expect(spawnMock.mock.calls[1]?.[2]?.stdio).toEqual(["ignore", "pipe", "pipe"]); |
| }); |
|
|
| it("does not retry on non-EBADF errors", async () => { |
| const spawnMock = vi.fn().mockImplementationOnce(() => { |
| const err = new Error("spawn ENOENT"); |
| (err as NodeJS.ErrnoException).code = "ENOENT"; |
| throw err; |
| }); |
|
|
| await expect( |
| spawnWithFallback({ |
| argv: ["missing"], |
| options: { stdio: ["pipe", "pipe", "pipe"] }, |
| fallbacks: [{ label: "safe-stdin", options: { stdio: ["ignore", "pipe", "pipe"] } }], |
| spawnImpl: spawnMock, |
| }), |
| ).rejects.toThrow(/ENOENT/); |
| expect(spawnMock).toHaveBeenCalledTimes(1); |
| }); |
| }); |
|
|
| describe("restart-recovery", () => { |
| it("skips recovery on first iteration and runs on subsequent iterations", () => { |
| const onRestart = vi.fn(); |
| const onIteration = createRestartIterationHook(onRestart); |
|
|
| expect(onIteration()).toBe(false); |
| expect(onRestart).not.toHaveBeenCalled(); |
|
|
| expect(onIteration()).toBe(true); |
| expect(onRestart).toHaveBeenCalledTimes(1); |
|
|
| expect(onIteration()).toBe(true); |
| expect(onRestart).toHaveBeenCalledTimes(2); |
| }); |
| }); |
|
|