From ad1a44161d75f47a515065f104ca2a70f08906ea Mon Sep 17 00:00:00 2001 From: A <6723574+louisgv@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:02:28 +0000 Subject: [PATCH] fix: show signal names instead of 'code null' when scripts are killed When a spawn script is killed by a signal (SIGKILL, SIGTERM, SIGHUP, etc.), Node.js returns exit code null. Previously this produced the confusing message "Script exited with code null". Now detects the actual signal and shows signal-specific guidance: OOM suggestions for SIGKILL, terminal reconnection tips for SIGHUP, spot instance warnings for SIGTERM. Fixes #1011 Agent: ux-engineer Co-Authored-By: Claude Opus 4.6 (1M context) --- cli/package.json | 2 +- .../__tests__/script-failure-guidance.test.ts | 105 +++++++++++++++++- cli/src/commands.ts | 57 +++++++++- 3 files changed, 158 insertions(+), 6 deletions(-) diff --git a/cli/package.json b/cli/package.json index 880df8eb1..6c28f3cb8 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.2.76", + "version": "0.2.77", "type": "module", "bin": { "spawn": "cli.js" diff --git a/cli/src/__tests__/script-failure-guidance.test.ts b/cli/src/__tests__/script-failure-guidance.test.ts index 95414df4b..774d53dce 100644 --- a/cli/src/__tests__/script-failure-guidance.test.ts +++ b/cli/src/__tests__/script-failure-guidance.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { getScriptFailureGuidance, getStatusDescription, buildRetryCommand } from "../commands"; +import { getScriptFailureGuidance, getSignalGuidance, getStatusDescription, buildRetryCommand } from "../commands"; /** * Tests for getScriptFailureGuidance() in commands.ts. @@ -459,6 +459,109 @@ describe("getScriptFailureGuidance", () => { }); }); +describe("getSignalGuidance", () => { + describe("SIGKILL", () => { + it("should mention OOM killer", () => { + const lines = getSignalGuidance("SIGKILL"); + const joined = lines.join("\n"); + expect(joined).toContain("SIGKILL"); + expect(joined).toContain("Out of memory"); + }); + + it("should suggest a larger instance size", () => { + const lines = getSignalGuidance("SIGKILL"); + const joined = lines.join("\n"); + expect(joined).toContain("larger instance size"); + }); + + it("should suggest checking cloud provider dashboard", () => { + const lines = getSignalGuidance("SIGKILL"); + const joined = lines.join("\n"); + expect(joined).toContain("cloud provider dashboard"); + }); + }); + + describe("SIGTERM", () => { + it("should mention process was terminated", () => { + const lines = getSignalGuidance("SIGTERM"); + const joined = lines.join("\n"); + expect(joined).toContain("terminated"); + expect(joined).toContain("SIGTERM"); + }); + + it("should mention server shutdown or billing", () => { + const lines = getSignalGuidance("SIGTERM"); + const joined = lines.join("\n"); + expect(joined).toContain("shutdown"); + }); + }); + + describe("SIGINT", () => { + it("should mention Ctrl+C", () => { + const lines = getSignalGuidance("SIGINT"); + const joined = lines.join("\n"); + expect(joined).toContain("Ctrl+C"); + }); + + it("should warn about orphaned servers", () => { + const lines = getSignalGuidance("SIGINT"); + const joined = lines.join("\n"); + expect(joined).toContain("cloud provider dashboard"); + }); + }); + + describe("SIGHUP", () => { + it("should mention terminal disconnection", () => { + const lines = getSignalGuidance("SIGHUP"); + const joined = lines.join("\n"); + expect(joined).toContain("terminal connection"); + expect(joined).toContain("SIGHUP"); + }); + + it("should suggest tmux/screen", () => { + const lines = getSignalGuidance("SIGHUP"); + const joined = lines.join("\n"); + expect(joined).toContain("tmux"); + }); + }); + + describe("unknown signal", () => { + it("should show the signal name for unknown signals", () => { + const lines = getSignalGuidance("SIGUSR1"); + const joined = lines.join("\n"); + expect(joined).toContain("SIGUSR1"); + }); + + it("should always return a non-empty array", () => { + const lines = getSignalGuidance("SIGFOO"); + expect(lines.length).toBeGreaterThan(0); + }); + }); + + describe("return type", () => { + it("should always return string arrays", () => { + const signals = ["SIGKILL", "SIGTERM", "SIGINT", "SIGHUP", "SIGUSR1"]; + for (const sig of signals) { + const lines = getSignalGuidance(sig); + expect(Array.isArray(lines)).toBe(true); + for (const line of lines) { + expect(typeof line).toBe("string"); + } + } + }); + + it("should produce different output for each handled signal", () => { + const sigkill = getSignalGuidance("SIGKILL").join("\n"); + const sigterm = getSignalGuidance("SIGTERM").join("\n"); + const sigint = getSignalGuidance("SIGINT").join("\n"); + const sighup = getSignalGuidance("SIGHUP").join("\n"); + expect(sigkill).not.toBe(sigterm); + expect(sigterm).not.toBe(sigint); + expect(sigint).not.toBe(sighup); + }); + }); +}); + describe("buildRetryCommand", () => { it("should return simple command without prompt", () => { expect(buildRetryCommand("claude", "sprite")).toBe("spawn claude sprite"); diff --git a/cli/src/commands.ts b/cli/src/commands.ts index 4ec4cc7bd..f0a48d77c 100644 --- a/cli/src/commands.ts +++ b/cli/src/commands.ts @@ -672,6 +672,45 @@ export function credentialHints(cloud: string, authHint?: string, verb = "Missin return lines; } +export function getSignalGuidance(signal: string): string[] { + switch (signal) { + case "SIGKILL": + return [ + "Script was forcibly killed (SIGKILL). Common causes:", + " - Out of memory (OOM killer terminated the process)", + " - The server may not have enough RAM for this agent", + " - Try a larger instance size or a different cloud provider", + " - Check your cloud provider dashboard to stop or delete any unused servers", + ]; + case "SIGTERM": + return [ + "Script was terminated (SIGTERM). Common causes:", + " - The process was stopped by the system or a supervisor", + " - Server shutdown or reboot in progress", + " - Cloud provider terminated the instance (spot/preemptible instance or billing issue)", + ]; + case "SIGINT": + return [ + "Script was interrupted (Ctrl+C).", + "Note: If a server was already created, it may still be running.", + " Check your cloud provider dashboard to stop or delete any unused servers.", + ]; + case "SIGHUP": + return [ + "Script lost its terminal connection (SIGHUP). Common causes:", + " - SSH session disconnected or timed out", + " - Terminal window was closed during execution", + " - Try using a more stable connection or a terminal multiplexer (tmux/screen)", + ]; + default: + return [ + `Script was killed by signal ${signal}.`, + " - The process was terminated by the system or another process", + " - Check your cloud provider dashboard for any orphaned servers", + ]; + } +} + export function getScriptFailureGuidance(exitCode: number | null, cloud: string, authHint?: string): string[] { switch (exitCode) { case 130: @@ -746,7 +785,13 @@ function reportScriptFailure(errMsg: string, cloud: string, agent: string, authH const exitCodeMatch = errMsg.match(/exited with code (\d+)/); const exitCode = exitCodeMatch ? parseInt(exitCodeMatch[1], 10) : null; - const lines = getScriptFailureGuidance(exitCode, cloud, authHint); + // Check for signal-killed messages (e.g. "killed by SIGKILL") + const signalMatch = errMsg.match(/killed by (SIG\w+)/); + const signal = signalMatch ? signalMatch[1] : null; + + const lines = signal + ? getSignalGuidance(signal) + : getScriptFailureGuidance(exitCode, cloud, authHint); console.error(""); for (const line of lines) console.error(line); console.error(""); @@ -766,7 +811,7 @@ export function isRetryableExitCode(errMsg: string): boolean { } function handleUserInterrupt(errMsg: string): void { - if (!errMsg.includes("interrupted by user")) return; + if (!errMsg.includes("interrupted by user") && !errMsg.includes("killed by SIGINT")) return; console.error(); p.log.warn("Script interrupted (Ctrl+C)."); p.log.warn("If a server was already created, it may still be running."); @@ -841,13 +886,17 @@ function runBash(script: string, prompt?: string): Promise { stdio: "inherit", env, }); - child.on("close", (code: number | null) => { + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { if (code === 0) resolve(); - else { + else if (code !== null) { const msg = code === 130 ? "Script interrupted by user (Ctrl+C)" : `Script exited with code ${code}`; reject(new Error(msg)); + } else { + // code is null when killed by a signal (SIGKILL, SIGTERM, etc.) + const sig = signal ?? "unknown signal"; + reject(new Error(`Script was killed by ${sig}`)); } }); child.on("error", reject);