Files
Adam Whitehurst 0c27e69867 fix(cli): allow rig add and single-node launch two minutes to complete (#1287)
`rig add` and single-node `rig launch` (including `--retry-startup-from`) pass a per-request 120 s timeout to the daemon, the same wait subset launches and `rig up` already use. A slow but successful start now returns its real result instead of "timed out, outcome unknown" after 5 s. A start the daemon fails still reports as soon as it answers, and a hung daemon still ends with the same "outcome unknown" error, after 120 s instead of 5 s, with one write and no retry. `--plan`, `--host`, the subset launch and the client's default for every other request keep their deadlines.
2026-10-14 17:38:44 -07:00

101 lines
4.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { addMemberCommand } from "../src/commands/add.js";
import { DaemonClient, DaemonTimeoutError } from "../src/client.js";
import type { StatusDeps } from "../src/commands/status.js";
/** Pair the real client with an isolated daemon lifecycle fixture for command tests. */
function deps(client: DaemonClient): StatusDeps {
return {
lifecycleDeps: {
spawn: vi.fn(), fetch: vi.fn(async () => ({ ok: true })), kill: vi.fn(() => true),
readFile: vi.fn(() => JSON.stringify({ pid: 1, port: 3000, db: "test.sqlite", startedAt: new Date().toISOString() })),
writeFile: vi.fn(), removeFile: vi.fn(), exists: vi.fn(() => true), mkdirp: vi.fn(),
openForAppend: vi.fn(() => 1), isProcessAlive: vi.fn(() => true),
},
clientFactory: () => client,
};
}
const fragment = (() => {
const dir = mkdtempSync(join(tmpdir(), "add-timeout-"));
const path = join(dir, "member.yaml");
writeFileSync(path, "member:\n id: reviewer\n runtime: claude-code\n agent_ref: local:agent\n profile: default\n cwd: /project\n");
return path;
})();
const LAUNCHED = '"result":{"podId":"p1","podNamespace":"dev","node":{"logicalId":"dev.reviewer","nodeId":"n1","status":"launched","sessionName":"dev-reviewer@rig-1"}}}';
let server: http.Server | undefined;
beforeEach(() => {
vi.stubEnv("OPENRIG_HOST_SELECTED", "local");
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
});
afterEach(async () => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllEnvs();
process.exitCode = undefined;
if (server) {
server.closeAllConnections();
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = undefined;
}
});
describe("rig add request deadline", () => {
it("waits for the member-add response beyond the default five seconds without retrying", async () => {
let posts = 0;
server = http.createServer(async (req, res) => {
for await (const _chunk of req) { /* drain */ }
if (req.method !== "POST") {
res.writeHead(404, { "Content-Type": "application/json" });
res.end("{}");
return;
}
posts++;
res.writeHead(201, { "Content-Type": "application/json" });
res.write('{"ok":true,');
const timer = setTimeout(() => res.end(LAUNCHED), 6_000);
res.on("close", () => clearTimeout(timer));
});
await new Promise<void>((resolve) => server!.listen(0, "127.0.0.1", resolve));
const client = new DaemonClient(`http://127.0.0.1:${(server.address() as AddressInfo).port}`);
await addMemberCommand(deps(client)).parseAsync(["node", "rig", "rig-1", "dev", fragment]);
expect(process.exitCode).toBeUndefined();
expect(console.log).toHaveBeenCalledWith(expect.stringContaining("[OK] dev.reviewer"));
expect(posts).toBe(1);
});
it("bounds the member-add request at 120 seconds with no automatic retry", async () => {
// setImmediate stays real so the command's lazy yaml import can finish before the clock moves.
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
let writes = 0;
const fetchImpl: typeof fetch = async (_url, init) => {
if (init?.method === "GET") return new Response(JSON.stringify([]));
writes++;
return new Promise<Response>((_resolve, reject) => {
init!.signal!.addEventListener("abort", () => reject(init!.signal!.reason), { once: true });
});
};
const client = new DaemonClient("http://127.0.0.1:3000", { fetchImpl });
let settled = false;
const result = addMemberCommand(deps(client)).parseAsync(["node", "rig", "rig-1", "dev", fragment])
.then(() => { settled = true; return null; }, (error: unknown) => { settled = true; return error; });
while (writes === 0) await new Promise<void>((resolve) => setImmediate(resolve));
await vi.advanceTimersByTimeAsync(119_999);
expect(writes).toBe(1);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(await result).toBeInstanceOf(DaemonTimeoutError);
expect(writes).toBe(1);
expect(vi.getTimerCount()).toBe(0);
});
});