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:
9
.changeset/fix-droid-model-discovery-process-storm.md
Normal file
9
.changeset/fix-droid-model-discovery-process-storm.md
Normal 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).
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user