FN-6808: handle AI CLI probe spawn failures
Resolve synchronous AI CLI probe launch failures as unavailable auth states. - Catch synchronous spawn failures in Claude and Droid probe helpers so fire-and-forget validation paths do not reject. - Return unavailable/unauthenticated sentinels for Droid model discovery and CLI presence/auth checks. - Add regression coverage for Claude and Droid presence/auth probes plus a patch changeset. Files changed: .../fn-6808-cli-probe-unhandled-rejection.md | 5 ++++ .../src/__tests__/process-manager.test.ts | 25 ++++++++++++++++++++ .../src/__tests__/process-manager.test.ts | 25 ++++++++++++++++++++ packages/pi-claude-cli/src/process-manager.ts | 12 +++++++++- .../src/process-manager.ts | 27 +++++++++++++++++++--- 5 files changed, 90 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-6808 Fusion-Task-Lineage: 79702ef2-e116-4313-a557-da17a6888c30
This commit is contained in:
5
.changeset/fn-6808-cli-probe-unhandled-rejection.md
Normal file
5
.changeset/fn-6808-cli-probe-unhandled-rejection.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Prevent bundled Droid and Claude CLI auth/presence probes from surfacing unhandled promise rejections when `spawn` throws synchronously, such as when test guards block real AI CLI auth commands. These probes now resolve as unavailable/unauthenticated instead of rejecting from fire-and-forget validation paths.
|
||||
@@ -415,6 +415,16 @@ describe("validateCliPresenceAsync", () => {
|
||||
const result = await validateCliPresenceAsync();
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves ok=false instead of rejecting when droid spawn throws synchronously", async () => {
|
||||
(spawn as any).mockImplementationOnce(() => {
|
||||
throw new Error("Real AI CLI launch blocked during tests: droid --version");
|
||||
});
|
||||
|
||||
await expect(validateCliPresenceAsync()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCliAuthAsync", () => {
|
||||
@@ -452,6 +462,21 @@ describe("validateCliAuthAsync", () => {
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("resolves false instead of rejecting when droid auth spawn throws synchronously", async () => {
|
||||
(spawn as any).mockImplementationOnce(() => {
|
||||
throw new Error(
|
||||
"Real AI CLI launch blocked during tests: droid auth status",
|
||||
);
|
||||
});
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
await expect(validateCliAuthAsync()).resolves.toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("not authenticated"),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI flags", () => {
|
||||
|
||||
@@ -414,6 +414,16 @@ describe("validateCliPresenceAsync", () => {
|
||||
const result = await validateCliPresenceAsync();
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves ok=false instead of rejecting when claude spawn throws synchronously", async () => {
|
||||
(spawn as any).mockImplementationOnce(() => {
|
||||
throw new Error("Real AI CLI launch blocked during tests: claude --version");
|
||||
});
|
||||
|
||||
await expect(validateCliPresenceAsync()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCliAuthAsync", () => {
|
||||
@@ -451,6 +461,21 @@ describe("validateCliAuthAsync", () => {
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("resolves false instead of rejecting when claude auth spawn throws synchronously", async () => {
|
||||
(spawn as any).mockImplementationOnce(() => {
|
||||
throw new Error(
|
||||
"Real AI CLI launch blocked during tests: claude auth status",
|
||||
);
|
||||
});
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
await expect(validateCliAuthAsync()).resolves.toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("not authenticated"),
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLI flags", () => {
|
||||
|
||||
@@ -219,10 +219,20 @@ export function captureStderr(proc: ChildProcess): () => string {
|
||||
* does this on every chat send), sync probes freeze every other request.
|
||||
* This async variant uses spawn so the loop keeps turning while the subprocess
|
||||
* starts up.
|
||||
*
|
||||
* FNXC:CliRuntime 2026-06-20-17:25:
|
||||
* FN-6808/FN-6801 require this fire-and-forget auth/presence probe to never reject. Catch synchronous spawn throws from the Vitest child-process guard or platform launch errors and resolve 127, matching the async error sentinel so callers degrade to unauthenticated/not-present instead of surfacing unhandled promise rejections.
|
||||
*/
|
||||
function runClaudeProbe(args: string[], timeoutMs = 5000): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("claude", args, { stdio: "ignore" });
|
||||
let proc: ChildProcess;
|
||||
try {
|
||||
proc = spawn("claude", args, { stdio: "ignore" });
|
||||
} catch {
|
||||
resolve(127);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
|
||||
@@ -219,10 +219,20 @@ export function captureStderr(proc: ChildProcess): () => string {
|
||||
* does this on every chat send), sync probes freeze every other request.
|
||||
* This async variant uses spawn so the loop keeps turning while the subprocess
|
||||
* starts up.
|
||||
*
|
||||
* FNXC:CliRuntime 2026-06-20-17:25:
|
||||
* FN-6808/FN-6801 require this fire-and-forget auth/presence probe to never reject. Catch synchronous spawn throws from the Vitest child-process guard or platform launch errors and resolve 127, matching the async error sentinel so callers degrade to unauthenticated/not-present instead of surfacing unhandled promise rejections.
|
||||
*/
|
||||
function runDroidProbe(args: string[], timeoutMs = 45000): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn("droid", args, { stdio: "ignore" });
|
||||
let proc: ChildProcess;
|
||||
try {
|
||||
proc = spawn("droid", args, { stdio: "ignore" });
|
||||
} catch {
|
||||
resolve(127);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
@@ -275,11 +285,22 @@ export async function validateCliAuthAsync(): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function discoverDroidModels(): Promise<string[]> {
|
||||
const attempts: string[][] = [["models", "--json"], ["model", "list", "--json"], ["models"]];
|
||||
const attempts: string[][] = [
|
||||
["models", "--json"],
|
||||
["model", "list", "--json"],
|
||||
["models"],
|
||||
];
|
||||
|
||||
for (const args of attempts) {
|
||||
const models = await new Promise<string[] | null>((resolve) => {
|
||||
const proc = spawn("droid", args, { stdio: ["ignore", "pipe", "ignore"] });
|
||||
let proc: ChildProcess;
|
||||
try {
|
||||
proc = spawn("droid", args, { stdio: ["ignore", "pipe", "ignore"] });
|
||||
} catch {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let out = "";
|
||||
proc.stdout?.on("data", (chunk: Buffer) => {
|
||||
out += chunk.toString();
|
||||
|
||||
Reference in New Issue
Block a user