Skip to content

Commit dcf0195

Browse files
fix(engines): stop a signal-killed child reporting success (#37)
1 parent 0800c31 commit dcf0195

8 files changed

Lines changed: 78 additions & 10 deletions

File tree

src/engines.mjs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,28 @@ export function runCmd(cmd, args = []) {
194194
});
195195
}
196196

197+
/**
198+
* True only when a child actually ran and exited 0. Node reports a signal death
199+
* as `code === null` (the signal name lands in `signal` instead), so a killed
200+
* child — OOM, a timeout wrapper's SIGTERM, Ctrl-C — must not read as success
201+
* just because it has no exit code.
202+
*/
203+
export function ranOk(r) {
204+
return Boolean(r?.ok) && r.code === 0;
205+
}
206+
207+
/**
208+
* Short human reason a `runCmd` result failed: "code 128", "SIGKILL", or the
209+
* spawn error message. Null when it succeeded.
210+
*/
211+
export function exitReason(r) {
212+
if (ranOk(r)) return null;
213+
if (r?.error) return r.error.message || String(r.error);
214+
if (r?.code != null) return `code ${r.code}`;
215+
if (r?.signal) return r.signal;
216+
return "unknown error";
217+
}
218+
197219
/**
198220
* Hand the current process streams to an external CLI. Arguments, cwd, and the
199221
* environment are inherited unchanged unless that target explicitly asks for

src/integrations.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export function printSkillTargets() {
114114
function summarize(results) {
115115
for (const r of results) {
116116
if (r.status === "added" || r.status === "installed") console.log(line(r.key, ok(r.status)));
117-
else if (r.status === "failed") console.log(line(r.key, err(`failed${r.code != null ? ` (code ${r.code})` : ""}`)));
117+
else if (r.status === "failed") console.log(line(r.key, err(`failed${r.code != null ? ` (code ${r.code})` : r.signal ? ` (${r.signal})` : ""}`)));
118118
else if (r.status === "not-installed") console.log(line(r.key, ash("not installed — /install " + r.key)));
119119
else console.log(line(r.key, ash(`skipped — ${r.reason}`)));
120120
}

src/mcp.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Register MCP (Model Context Protocol) servers across every engine that
22
// supports them, from one canonical definition. MoshCode drives each engine's
33
// own `mcp add` so the engine owns its config format. See prd/0003.
4-
import { ENGINES, isInstalled, runCmd } from "./engines.mjs";
4+
import { ENGINES, isInstalled, ranOk, runCmd } from "./engines.mjs";
55

66
// Coding engines that can register MCP servers. Aider has no MCP support.
77
export const MCP_ENGINES = ["claude", "gemini", "codex", "opencode"];
@@ -112,7 +112,7 @@ export async function runMcpAdd(plan, { run = runCmd } = {}) {
112112
if (item.skip) { results.push({ key: item.key, status: "skipped", reason: item.skip }); continue; }
113113
if (!item.installed) { results.push({ key: item.key, status: "not-installed" }); continue; }
114114
const r = await run(item.bin, item.argv);
115-
results.push({ key: item.key, status: r.ok && (r.code === 0 || r.code == null) ? "added" : "failed", code: r.code });
115+
results.push({ key: item.key, status: ranOk(r) ? "added" : "failed", code: r.code, signal: r.signal ?? null });
116116
}
117117
return results;
118118
}

src/skills.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// source into its personal skills dir. See prd/0003.
44
import os from "node:os";
55
import path from "node:path";
6-
import { ENGINES, isInstalled, runCmd } from "./engines.mjs";
6+
import { ENGINES, isInstalled, ranOk, runCmd } from "./engines.mjs";
77

88
// Coding engines with a skills primitive. Codex/OpenCode/Aider have none.
99
export const SKILL_ENGINES = ["claude", "gemini"];
@@ -66,7 +66,7 @@ export async function runSkillInstall(plan, { run = runCmd } = {}) {
6666
if (item.skip) { results.push({ key: item.key, status: "skipped", reason: item.skip }); continue; }
6767
if (!item.installed) { results.push({ key: item.key, status: "not-installed" }); continue; }
6868
const r = await run(item.cmd, item.args);
69-
results.push({ key: item.key, status: r.ok && (r.code === 0 || r.code == null) ? "installed" : "failed", code: r.code });
69+
results.push({ key: item.key, status: ranOk(r) ? "installed" : "failed", code: r.code, signal: r.signal ?? null });
7070
}
7171
return results;
7272
}

src/upgrade.mjs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { fileURLToPath } from "node:url";
55
import path from "node:path";
66
import fs from "node:fs";
7-
import { ENGINES, engineStatus, resolveEngine, upgradeSpec, runCmd } from "./engines.mjs";
7+
import { ENGINES, engineStatus, exitReason, ranOk, resolveEngine, upgradeSpec, runCmd } from "./engines.mjs";
88
import { TOOLS, resolveTool, toolStatus, toolUpgradeSpec } from "./tools.mjs";
99

1010
// Self-upgrade re-runs the moshcode installer's `update` path. Defaults to the
@@ -125,9 +125,9 @@ export async function runUpgrade(targets = [], io = {}) {
125125
rule();
126126
const r = await runCmd(spec.cmd, spec.args);
127127
rule();
128-
const ok = r.ok && (r.code == null || r.code === 0);
129-
log(ok ? `✓ ${name} up to date` : `✗ ${name} upgrade failed${r.code != null ? ` (code ${r.code})` : r.error ? `: ${r.error.message || r.error}` : ""}`);
130-
results.push({ name, ok, code: r.code });
128+
const ok = ranOk(r);
129+
log(ok ? `✓ ${name} up to date` : `✗ ${name} upgrade failed (${exitReason(r)})`);
130+
results.push({ name, ok, code: r.code, signal: r.signal ?? null });
131131
return ok;
132132
};
133133

test/engines.test.mjs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url";
1414
import { spawn } from "node:child_process";
1515
import test from "node:test";
1616

17-
import { ENGINES, agentLaunchArgs } from "../src/engines.mjs";
17+
import { ENGINES, agentLaunchArgs, exitReason, ranOk, runCmd } from "../src/engines.mjs";
1818

1919
const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url));
2020
// The autonomous-session bypass flags each engine declares (engine.agentArgs).
@@ -130,3 +130,30 @@ test("bare engine launch remains a raw passthrough", async () => {
130130
assert.deepEqual(JSON.parse(result.stdout), ["--model", "sonnet"]);
131131
assert.equal(result.stderr, "");
132132
});
133+
134+
test("a signal-killed child is a failure, not a codeless success", async () => {
135+
const r = await runCmd("bash", ["-c", "kill -9 $$"]);
136+
137+
// Node reports a signal death with code === null — the old `code == null`
138+
// success check read that as "exited cleanly".
139+
assert.equal(r.ok, true);
140+
assert.equal(r.code, null);
141+
assert.equal(r.signal, "SIGKILL");
142+
143+
assert.equal(ranOk(r), false);
144+
assert.equal(exitReason(r), "SIGKILL");
145+
});
146+
147+
test("ranOk and exitReason cover clean exits, bad codes, and spawn errors", async () => {
148+
const clean = await runCmd("bash", ["-c", "exit 0"]);
149+
assert.equal(ranOk(clean), true);
150+
assert.equal(exitReason(clean), null);
151+
152+
const bad = await runCmd("bash", ["-c", "exit 128"]);
153+
assert.equal(ranOk(bad), false);
154+
assert.equal(exitReason(bad), "code 128");
155+
156+
const missing = await runCmd("moshcode-does-not-exist-xyz", []);
157+
assert.equal(ranOk(missing), false);
158+
assert.match(exitReason(missing), /ENOENT|not found|spawn/i);
159+
});

test/mcp.test.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,13 @@ fs.writeFileSync(path.join(process.env.CAPS, "${name}.json"), JSON.stringify(pro
142142
assert.deepEqual(JSON.parse(readFileSync(path.join(caps, "codex.json"), "utf8")), ["mcp", "add", "sentry", "--url", "https://mcp.sentry.dev/mcp"]);
143143
assert.deepEqual(JSON.parse(readFileSync(path.join(caps, "opencode.json"), "utf8")), ["mcp", "add", "sentry", "--url", "https://mcp.sentry.dev/mcp"]);
144144
});
145+
146+
test("an mcp add killed by a signal reports failed, not added", async () => {
147+
const spec = { name: "s", target: "https://x.dev/mcp", env: [], headers: [] };
148+
const plan = planMcpAdd(spec, { installedSet: new Set(["claude"]) });
149+
const results = await runMcpAdd(plan, { run: async () => ({ ok: true, code: null, signal: "SIGTERM" }) });
150+
const claude = results.find((r) => r.key === "claude");
151+
152+
assert.equal(claude.status, "failed");
153+
assert.equal(claude.signal, "SIGTERM");
154+
});

test/skills.test.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,12 @@ test("runSkillInstall summarizes installed / not-installed", async () => {
6464
assert.equal(byKey.gemini, "installed");
6565
assert.equal(byKey.claude, "not-installed");
6666
});
67+
68+
test("a skill install killed by a signal reports failed, not installed", async () => {
69+
const plan = planSkillInstall({ source: "https://x/y", name: "y" }, { installedSet: new Set(["gemini"]) });
70+
const results = await runSkillInstall(plan, { run: async () => ({ ok: true, code: null, signal: "SIGKILL" }) });
71+
const gemini = results.find((r) => r.key === "gemini");
72+
73+
assert.equal(gemini.status, "failed");
74+
assert.equal(gemini.signal, "SIGKILL");
75+
});

0 commit comments

Comments
 (0)