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. */
|
/** Registry of active subprocesses for cleanup on teardown. */
|
||||||
const activeProcesses = new Set<ChildProcess>();
|
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.
|
* Register a subprocess in the global process registry.
|
||||||
* The process is automatically removed from the registry when it exits.
|
* The process is automatically removed from the registry when it exits.
|
||||||
@@ -284,65 +292,73 @@ export async function validateCliAuthAsync(): Promise<boolean> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function discoverDroidModels(): Promise<string[]> {
|
/**
|
||||||
const attempts: string[][] = [
|
* Parse model IDs out of `droid exec --help`. The help text lists the catalog
|
||||||
["models", "--json"],
|
* under `Available Models:` and `Custom Models:` headers, each entry indented as
|
||||||
["model", "list", "--json"],
|
* ` <model-id> <description>`. The trailing `Model details:` section (lines
|
||||||
["models"],
|
* like ` - Claude Opus 4.8: ...`) is intentionally excluded — those are prose,
|
||||||
];
|
* not IDs. Exported for unit testing.
|
||||||
|
*/
|
||||||
for (const args of attempts) {
|
export function parseDroidModelsFromHelp(helpText: string): string[] {
|
||||||
const models = await new Promise<string[] | null>((resolve) => {
|
const ids: string[] = [];
|
||||||
let proc: ChildProcess;
|
let collecting = false;
|
||||||
try {
|
for (const line of helpText.split(/\r?\n/)) {
|
||||||
proc = spawn("droid", args, { stdio: ["ignore", "pipe", "ignore"] });
|
// Section header at column 0, e.g. "Available Models:" / "Custom Models:".
|
||||||
} catch {
|
if (/^[A-Za-z][A-Za-z ]*Models:\s*$/.test(line)) {
|
||||||
resolve(null);
|
collecting = true;
|
||||||
return;
|
continue;
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
// 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 Array.from(new Set(ids));
|
||||||
return [];
|
}
|
||||||
|
|
||||||
|
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