FN-6878: register Droid provider before probes
Register Droid provider startup without waiting on local binary probes. - Register the Droid CLI provider synchronously with an empty model list, then refresh discovered models asynchronously. - Harden Droid runtime probes to convert spawn errors and timeouts into unavailable sentinel results. - Add coverage for non-blocking startup, failed probes, unavailable binaries, and process cleanup behavior. - Add a patch changeset for the published Fusion CLI package. Files changed: .changeset/fn-6878-droid-boot.md | 5 ++ packages/droid-cli/index.ts | 73 +++++++++++------- packages/droid-cli/src/__tests__/index.test.ts | 52 ++++++++++++- .../src/__tests__/process-manager.test.ts | 58 +++++++-------- .../src/__tests__/discover-models.test.ts | 8 ++ .../src/__tests__/probe.test.ts | 43 ++++++++++- .../src/__tests__/startup-probes.test.ts | 86 ++++++++++++++++++++++ plugins/fusion-plugin-droid-runtime/src/probe.ts | 32 +++++--- 8 files changed, 283 insertions(+), 74 deletions(-) Fusion-Task-Id: FN-6878 Fusion-Task-Lineage: 4e44a6e8-0eea-4867-a172-3bdc0c6368e1
This commit is contained in:
@@ -106,4 +106,12 @@ describe("discoverDroidModels", () => {
|
||||
|
||||
await expect(discoverDroidModels()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] instead of rejecting when spawn throws synchronously", async () => {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
throw new Error("Real AI CLI launch blocked during tests: droid exec --help");
|
||||
});
|
||||
|
||||
await expect(discoverDroidModels()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
@@ -10,11 +10,26 @@ vi.mock("node:child_process", () => ({
|
||||
|
||||
import { probeDroidBinary, resolveDroidBinaryPath } from "../probe.js";
|
||||
|
||||
function makeProbeProc() {
|
||||
const proc = new EventEmitter() as any;
|
||||
proc.stdout = new PassThrough();
|
||||
proc.stderr = new PassThrough();
|
||||
proc.killed = false;
|
||||
proc.kill = vi.fn(() => {
|
||||
proc.killed = true;
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
describe("probeDroidBinary", () => {
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns unavailable when binary is missing", async () => {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
const proc = new EventEmitter() as any;
|
||||
@@ -29,6 +44,32 @@ describe("probeDroidBinary", () => {
|
||||
expect(result.reason).toContain("Binary not found or not executable");
|
||||
});
|
||||
|
||||
it("returns unavailable and SIGKILLs when the binary hangs", async () => {
|
||||
vi.useFakeTimers();
|
||||
const proc = makeProbeProc();
|
||||
spawnMock.mockImplementationOnce(() => proc);
|
||||
|
||||
const pending = probeDroidBinary({ timeoutMs: 50 });
|
||||
await vi.advanceTimersByTimeAsync(51);
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
available: false,
|
||||
reason: "Probe timed out after 50ms",
|
||||
});
|
||||
expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
});
|
||||
|
||||
it("returns unavailable when spawn throws synchronously", async () => {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
throw new Error("Real AI CLI launch blocked during tests: droid --version");
|
||||
});
|
||||
|
||||
await expect(probeDroidBinary({ timeoutMs: 10 })).resolves.toMatchObject({
|
||||
available: false,
|
||||
reason: "Binary not found or not executable: droid",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns available and version on success", async () => {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
const proc = new EventEmitter() as any;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
import { validateCliAuthAsync, validateCliPresenceAsync } from "../process-manager.js";
|
||||
|
||||
function makeProbeProc() {
|
||||
const proc = new EventEmitter() as any;
|
||||
proc.killed = false;
|
||||
proc.kill = vi.fn(() => {
|
||||
proc.killed = true;
|
||||
});
|
||||
return proc;
|
||||
}
|
||||
|
||||
describe("Droid startup validation probes", () => {
|
||||
beforeEach(() => {
|
||||
spawnMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("resolves unavailable when `droid --version` emits ENOENT", async () => {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
const proc = makeProbeProc();
|
||||
queueMicrotask(() => proc.emit("error", Object.assign(new Error("not found"), { code: "ENOENT" })));
|
||||
return proc;
|
||||
});
|
||||
|
||||
await expect(validateCliPresenceAsync()).resolves.toMatchObject({ ok: false });
|
||||
expect(spawnMock).toHaveBeenCalledWith("droid", ["--version"], expect.objectContaining({ stdio: "ignore" }));
|
||||
});
|
||||
|
||||
it("resolves ok when `droid --version` exits 0", async () => {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
const proc = makeProbeProc();
|
||||
queueMicrotask(() => proc.emit("exit", 0));
|
||||
return proc;
|
||||
});
|
||||
|
||||
await expect(validateCliPresenceAsync()).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("SIGKILLs and resolves unavailable when `droid --version` hangs", async () => {
|
||||
vi.useFakeTimers();
|
||||
const proc = makeProbeProc();
|
||||
spawnMock.mockImplementationOnce(() => proc);
|
||||
|
||||
const pending = validateCliPresenceAsync();
|
||||
await vi.advanceTimersByTimeAsync(45_001);
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ ok: false });
|
||||
expect(proc.kill).toHaveBeenCalledWith("SIGKILL");
|
||||
});
|
||||
|
||||
it("resolves false when `droid auth status` exits non-zero", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
const proc = makeProbeProc();
|
||||
queueMicrotask(() => proc.emit("exit", 1));
|
||||
return proc;
|
||||
});
|
||||
|
||||
await expect(validateCliAuthAsync()).resolves.toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not authenticated"));
|
||||
expect(spawnMock).toHaveBeenCalledWith("droid", ["auth", "status"], expect.objectContaining({ stdio: "ignore" }));
|
||||
});
|
||||
|
||||
it("resolves false instead of rejecting when auth spawn throws synchronously", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
throw new Error("Real AI CLI launch blocked during tests: droid auth status");
|
||||
});
|
||||
|
||||
await expect(validateCliAuthAsync()).resolves.toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("not authenticated"));
|
||||
});
|
||||
});
|
||||
@@ -18,25 +18,37 @@ export function resolveDroidBinaryPath(settings?: Record<string, unknown>): stri
|
||||
|
||||
async function run(binary: string, args: string[], timeoutMs = 2000): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
} catch {
|
||||
resolve({ code: 127, stdout, stderr });
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:CliRuntime 2026-06-21-12:00:
|
||||
Droid binary probes run on dashboard and engine startup status paths, so they must never reject or wait forever. Convert synchronous spawn guards, ENOENT, and timeout hangs into sentinel exit codes so boot degrades provider availability instead of blocking on a broken local `droid` install.
|
||||
*/
|
||||
let settled = false;
|
||||
const settle = (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ code, stdout, stderr });
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
try { child.kill("SIGKILL"); } catch {
|
||||
// ignore kill errors
|
||||
}
|
||||
resolve({ code: 124, stdout, stderr });
|
||||
settle(124);
|
||||
}, timeoutMs);
|
||||
child.stdout?.on("data", (c: Buffer) => { stdout += c.toString("utf-8"); });
|
||||
child.stderr?.on("data", (c: Buffer) => { stderr += c.toString("utf-8"); });
|
||||
child.on("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code: 127, stdout, stderr });
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
child.on("error", () => settle(127));
|
||||
child.on("close", (code) => settle(code));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user