fix(droid): stop model-discovery process storm; read catalog from droid exec --help

discoverDroidModels ran `droid models`/`droid model list`, which aren't real
droid commands — they parse as a prompt and launch a persistent
`droid exec --stream-jsonrpc` agent session that never exits, leaking a process
per call. The dashboard reloads the droid extension on every chat-send, so these
piled into dozens of orphaned `droid` processes.

Switch discovery to parse `droid exec --help` (lists Available + Custom models,
exits cleanly) via new parseDroidModelsFromHelp, and add a SIGKILL-on-timeout
guard so a wedged spawn can never leak. Verified against the real binary: 46
models, 0 leaked processes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 03:40:12 -07:00
parent 4672203acd
commit 68c4053a85
3 changed files with 193 additions and 59 deletions

View File

@@ -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).

View File

@@ -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 <id> 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([]);
});
});

View File

@@ -159,6 +159,14 @@ export function forceKillProcess(proc: ChildProcess): void {
/** Registry of active subprocesses for cleanup on teardown. */
const activeProcesses = new Set<ChildProcess>();
/**
* 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<boolean> {
return false;
}
export async function discoverDroidModels(): Promise<string[]> {
const attempts: string[][] = [
["models", "--json"],
["model", "list", "--json"],
["models"],
];
for (const args of attempts) {
const models = await new Promise<string[] | null>((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
* ` <model-id> <description>`. 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 " <id> <description>"; 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<string[]> {
// 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<string[]>((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)));
});
}