diff --git a/.changeset/fix-droid-model-discovery-process-storm.md b/.changeset/fix-droid-model-discovery-process-storm.md new file mode 100644 index 0000000000..acae2dab30 --- /dev/null +++ b/.changeset/fix-droid-model-discovery-process-storm.md @@ -0,0 +1,9 @@ +--- +"@runfusion/fusion": patch +--- + +Fix the Droid runtime model discovery spawning a runaway storm of leaked `droid` processes. + +`discoverDroidModels` invoked `droid models --json` / `droid model list --json`, but the droid CLI has no such commands — an unknown subcommand is parsed as a *prompt*, so each call launched a full agent session (a persistent `droid exec --stream-jsonrpc` backend) that never exited. The promise never settled and the process leaked; because the dashboard re-loads the droid extension on every chat-send, these piled up into dozens of orphaned `droid` processes. + +Discovery now reads the catalog from `droid exec --help` (which lists `Available Models:` + `Custom Models:` and exits cleanly), parsed via the new `parseDroidModelsFromHelp` helper. A SIGKILL-on-timeout guard (`DROID_MODEL_DISCOVERY_TIMEOUT_MS`) ensures any wedged spawn is killed and the promise always settles, so a single discovery call can never leak a process again. Verified end-to-end against the real binary (46 models incl. custom, 0 leaked processes). diff --git a/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts b/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts new file mode 100644 index 0000000000..bb83f97d24 --- /dev/null +++ b/plugins/fusion-plugin-droid-runtime/src/__tests__/discover-models.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; + +const spawnMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:child_process", () => ({ + spawn: spawnMock, +})); + +import { discoverDroidModels, parseDroidModelsFromHelp } from "../process-manager.js"; + +// Trimmed but faithful sample of real `droid exec --help` output. +const HELP_SAMPLE = `Usage: droid exec [options] [prompt] + +Options: + -m, --model Model ID to use (default: claude-opus-4-8) + --list-tools List available tools for the selected model and exit + +Available Models: + claude-opus-4-8 Claude Opus 4.8 (default) + claude-sonnet-4-6 Claude Sonnet 4.6 + gpt-5.5 GPT-5.5 + glm-5.2 Droid Core (GLM-5.2) + +Custom Models: + custom:Kimi-K2.5-Turbo-0 Kimi K2.5 Turbo + custom:CC:-Opus-4.6-(Max)-0 DroidProxy-CC: Opus 4.6 (Max) + +Model details: + - Claude Opus 4.8: supports reasoning: Yes; default: high + - Claude Sonnet 4.6: supports reasoning: Yes; default: high +`; + +function makeProc() { + const proc = new EventEmitter() as any; + proc.stdout = new PassThrough(); + proc.killed = false; + proc.kill = vi.fn(() => { + proc.killed = true; + }); + return proc; +} + +describe("parseDroidModelsFromHelp", () => { + it("extracts IDs from Available + Custom sections, excluding Model details prose", () => { + expect(parseDroidModelsFromHelp(HELP_SAMPLE)).toEqual([ + "claude-opus-4-8", + "claude-sonnet-4-6", + "gpt-5.5", + "glm-5.2", + "custom:Kimi-K2.5-Turbo-0", + "custom:CC:-Opus-4.6-(Max)-0", + ]); + }); + + it("returns [] when no model sections are present", () => { + expect(parseDroidModelsFromHelp("Usage: droid exec\n\nOptions:\n -h, --help\n")).toEqual([]); + }); +}); + +describe("discoverDroidModels", () => { + beforeEach(() => { + spawnMock.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("invokes `droid exec --help` (never a hanging `models`/`model list` command)", async () => { + spawnMock.mockImplementationOnce(() => { + const proc = makeProc(); + queueMicrotask(() => { + proc.stdout.write(HELP_SAMPLE); + proc.emit("exit", 0); + }); + return proc; + }); + + const models = await discoverDroidModels(); + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(spawnMock).toHaveBeenCalledWith("droid", ["exec", "--help"], expect.anything()); + expect(models).toContain("claude-opus-4-8"); + expect(models).toContain("custom:Kimi-K2.5-Turbo-0"); + }); + + it("SIGKILLs and returns [] when the spawn hangs (no exit event)", async () => { + vi.useFakeTimers(); + const proc = makeProc(); + spawnMock.mockImplementationOnce(() => proc); + + const pending = discoverDroidModels(); + await vi.advanceTimersByTimeAsync(10_000 + 10); + + await expect(pending).resolves.toEqual([]); + expect(proc.kill).toHaveBeenCalledWith("SIGKILL"); + }); + + it("returns [] on spawn error", async () => { + spawnMock.mockImplementationOnce(() => { + const proc = makeProc(); + queueMicrotask(() => proc.emit("error", new Error("ENOENT"))); + return proc; + }); + + await expect(discoverDroidModels()).resolves.toEqual([]); + }); +}); diff --git a/plugins/fusion-plugin-droid-runtime/src/process-manager.ts b/plugins/fusion-plugin-droid-runtime/src/process-manager.ts index ad22ac5b9f..cefa0717f0 100644 --- a/plugins/fusion-plugin-droid-runtime/src/process-manager.ts +++ b/plugins/fusion-plugin-droid-runtime/src/process-manager.ts @@ -159,6 +159,14 @@ export function forceKillProcess(proc: ChildProcess): void { /** Registry of active subprocesses for cleanup on teardown. */ const activeProcesses = new Set(); +/** + * Hard ceiling on a single `droid models`/`droid model list` discovery spawn. + * The droid CLI can keep stdout open via its stream-jsonrpc backend, so this + * bound guarantees the spawn is SIGKILLed and the promise settles. Kept short + * because discovery runs on the dashboard's per-session extension load path. + */ +const DROID_MODEL_DISCOVERY_TIMEOUT_MS = 10_000; + /** * Register a subprocess in the global process registry. * The process is automatically removed from the registry when it exits. @@ -284,65 +292,73 @@ export async function validateCliAuthAsync(): Promise { return false; } -export async function discoverDroidModels(): Promise { - const attempts: string[][] = [ - ["models", "--json"], - ["model", "list", "--json"], - ["models"], - ]; - - for (const args of attempts) { - const models = await new Promise((resolve) => { - 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(); - }); - proc.once("error", () => resolve(null)); - proc.once("exit", (code) => { - if (code !== 0) return resolve(null); - const trimmed = out.trim(); - if (!trimmed) return resolve([]); - try { - const parsed = JSON.parse(trimmed); - if (Array.isArray(parsed)) { - return resolve( - parsed - .map((entry) => - typeof entry === "string" - ? entry - : typeof entry?.id === "string" - ? entry.id - : typeof entry?.name === "string" - ? entry.name - : undefined, - ) - .filter((id): id is string => Boolean(id)), - ); - } - } catch { - // not json, fall through to line parsing - } - resolve( - trimmed - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean), - ); - }); - }); - - if (models && models.length > 0) { - return Array.from(new Set(models)); +/** + * Parse model IDs out of `droid exec --help`. The help text lists the catalog + * under `Available Models:` and `Custom Models:` headers, each entry indented as + * ` `. The trailing `Model details:` section (lines + * like ` - Claude Opus 4.8: ...`) is intentionally excluded — those are prose, + * not IDs. Exported for unit testing. + */ +export function parseDroidModelsFromHelp(helpText: string): string[] { + const ids: string[] = []; + let collecting = false; + for (const line of helpText.split(/\r?\n/)) { + // Section header at column 0, e.g. "Available Models:" / "Custom Models:". + if (/^[A-Za-z][A-Za-z ]*Models:\s*$/.test(line)) { + collecting = true; + continue; } + // Any other non-indented, non-empty line ends the current section + // (notably "Model details:"). + if (collecting && line.trim() && !/^\s/.test(line)) { + collecting = false; + } + if (!collecting) continue; + // Indented " "; the id is the first whitespace-delimited + // token (handles `custom:CC:-Opus-4.6-(Max)-0` and the like — no spaces). + const match = line.match(/^\s+(\S+)\s{2,}\S/); + if (match) ids.push(match[1]); } - - return []; + return Array.from(new Set(ids)); +} + +export async function discoverDroidModels(): Promise { + // The droid CLI has no `models`/`model list` command — those parse as a + // *prompt* and launch a hung agent session. The catalog is printed by + // `droid exec --help` (and exits cleanly). + return new Promise((resolve) => { + let proc: ChildProcess; + try { + proc = spawn("droid", ["exec", "--help"], { stdio: ["ignore", "pipe", "ignore"] }); + } catch { + resolve([]); + return; + } + + // FNXC:CliRuntime 2026-06-21: keep discovery bounded. `droid exec --help` + // exits on its own, but a SIGKILL-on-timeout guard ensures a wedged spawn + // can never leak (the prior `droid models` form launched a persistent + // stream-jsonrpc backend that never exited, piling up into a process storm + // because the dashboard re-loads this extension per chat-send). + let settled = false; + const settle = (value: string[]) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + if (!proc.killed) proc.kill("SIGKILL"); + } catch { + // already dead + } + resolve(value); + }; + const timer = setTimeout(() => settle([]), DROID_MODEL_DISCOVERY_TIMEOUT_MS); + + let out = ""; + proc.stdout?.on("data", (chunk: Buffer) => { + out += chunk.toString(); + }); + proc.once("error", () => settle([])); + proc.once("exit", () => settle(parseDroidModelsFromHelp(out))); + }); }