This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #72 from compoundingtech/fix/surface-signal-death

fix(server): surface a child's signal death (OOM SIGKILL) instead of recording exit 0

authored by

Nathan and committed by
GitHub
(Jul 11, 2026, 11:57 AM +0200) 2daae72e 6981c1cd

+113 -8
+1 -1
docs/disk-layout.md
··· 70 70 | `focus_request` | — | 71 71 | `cursor_visible` | — | 72 72 | `session_start` | `tags?` | 73 - | `session_exit` | `exitCode` | 73 + | `session_exit` | `exitCode, signal?` — signal death (e.g. OOM SIGKILL) surfaces as `exitCode` = 128 + `signal` (SIGKILL 9 → 137), matching shell `$?`; `signal` carries the raw number | 74 74 | `session_exec` | `previousCommand, command` | 75 75 | `session_respawn` | — (`pty gc` respawned a `strategy=permanent` session) | 76 76 | `session_abandoned` | `reason: "cwd-gone" \| "idle", idleDays?` — (`pty gc` reaped a live permanent session detected as abandoned) |
+9 -1
src/events.ts
··· 68 68 69 69 export interface SessionExitEvent extends EventBase { 70 70 type: "session_exit"; 71 + /** The child's exit status. A signal death (e.g. OOM SIGKILL) is surfaced as 72 + * 128 + signal (SIGKILL 9 → 137), matching shell `$?` semantics, so a 73 + * consumer gating on "nonzero" catches it. */ 71 74 exitCode: number; 75 + /** The raw signal number when the child was killed by a signal (absent on a 76 + * normal exit). `exitCode` already encodes it as 128 + signal. */ 77 + signal?: number; 72 78 } 73 79 74 80 export interface SessionExecEvent extends EventBase { ··· 503 509 return `${prefix} started${tagStr}`; 504 510 } 505 511 case "session_exit": 506 - return `${prefix} exited (code ${event.exitCode})`; 512 + return event.signal 513 + ? `${prefix} killed by signal ${event.signal} (code ${event.exitCode})` 514 + : `${prefix} exited (code ${event.exitCode})`; 507 515 case "session_exec": 508 516 return `${prefix} exec ${event.command} (was ${event.previousCommand})`; 509 517 case "session_respawn":
+15 -6
src/server.ts
··· 498 498 } 499 499 }); 500 500 501 - this.ptyProcess.onExit(({ exitCode }) => { 501 + this.ptyProcess.onExit(({ exitCode, signal }) => { 502 502 this.exited = true; 503 - this.exitCode = exitCode; 504 - this.broadcast(encodeExit(exitCode)); 505 - this.emitEvent(EventType.SESSION_EXIT, { exitCode }); 503 + // A signal death (e.g. an OS OOM SIGKILL) arrives from node-pty with a 504 + // nonzero `signal` and often exitCode 0 — if we recorded only the raw 505 + // exitCode, a killed process would look like a clean finish and any 506 + // consumer gating on "nonzero exit" (convoy's crash→ding) would miss it. 507 + // Surface it the way a shell does: 128 + signal (SIGKILL 9 → 137). 508 + const code = signal ? 128 + signal : exitCode; 509 + this.exitCode = code; 510 + this.broadcast(encodeExit(code)); 511 + this.emitEvent(EventType.SESSION_EXIT, { 512 + exitCode: code, 513 + ...(signal ? { signal } : {}), 514 + }); 506 515 // Save exit status immediately so the session shows as "exited" 507 516 // in pty list during the cleanup window. lastLines may be incomplete 508 517 // here since PTY data could still be in-flight — close() will 509 518 // update with the final output. 510 - this.saveExitMetadata(exitCode); 519 + this.saveExitMetadata(code); 511 520 this.resolveChildExited(); 512 - options.onExit?.(exitCode); 521 + options.onExit?.(code); 513 522 }); 514 523 515 524 // Create Unix socket server
+88
tests/exit-signal.test.ts
··· 1 + import { describe, it, expect, afterAll } from "vitest"; 2 + import * as fs from "node:fs"; 3 + import * as os from "node:os"; 4 + import * as path from "node:path"; 5 + import { fileURLToPath } from "node:url"; 6 + import { spawnSync, execFileSync } from "node:child_process"; 7 + 8 + const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 + const nodeBin = process.execPath; 10 + const cliPath = path.join(__dirname, "..", "dist", "cli.js"); 11 + const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-sig-")); 12 + const bgPids: number[] = []; 13 + afterAll(() => { 14 + for (const pid of bgPids) { try { process.kill(pid, "SIGKILL"); } catch {} } 15 + fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); 16 + }); 17 + 18 + function runCli(dir: string, args: string[]) { 19 + return spawnSync(nodeBin, [cliPath, ...args], { 20 + env: { ...process.env, PTY_SESSION_DIR: dir, PTY_ROOT_LEGACY_SILENT: "1" }, 21 + encoding: "utf8", timeout: 15000, 22 + }); 23 + } 24 + 25 + function createSession(dir: string, name: string, cmd: string[]): number { 26 + expect(runCli(dir, ["run", "-d", "--id", name, "--", ...cmd]).status).toBe(0); 27 + const pid = Number(fs.readFileSync(path.join(dir, `${name}.pid`), "utf8").trim()); 28 + bgPids.push(pid); 29 + return pid; 30 + } 31 + 32 + function readMeta(dir: string, name: string): any { 33 + return JSON.parse(fs.readFileSync(path.join(dir, `${name}.json`), "utf8")); 34 + } 35 + 36 + async function waitForExit(dir: string, name: string): Promise<any> { 37 + const start = Date.now(); 38 + while (Date.now() - start < 8000) { 39 + try { 40 + const m = readMeta(dir, name); 41 + if (m.exitedAt) return m; 42 + } catch {} 43 + await new Promise((r) => setTimeout(r, 50)); 44 + } 45 + throw new Error(`session "${name}" never recorded an exit`); 46 + } 47 + 48 + describe("pty surfaces a signal death (OOM SIGKILL) instead of losing it", () => { 49 + it("a SIGKILL'd child is recorded as 128+signal (137), not exit 0", async () => { 50 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 51 + const daemonPid = createSession(dir, "sk", ["sh", "-c", "exec sleep 300"]); 52 + 53 + // The session's leaf is the daemon's direct child (exec replaces the sh). 54 + const leaf = Number( 55 + execFileSync("pgrep", ["-P", String(daemonPid)], { encoding: "utf8" }).trim().split("\n")[0], 56 + ); 57 + expect(Number.isInteger(leaf)).toBe(true); 58 + 59 + process.kill(leaf, "SIGKILL"); // simulate an OS OOM kill 60 + 61 + const meta = await waitForExit(dir, "sk"); 62 + // Was exit 0 (clean finish) before the fix — a real crash that convoy's 63 + // nonzero gate would have missed. Now surfaced as 128 + SIGKILL(9) = 137. 64 + expect(meta.exitCode).toBe(137); 65 + 66 + // The raw signal is also surfaced on the session_exit event. 67 + const events = fs.readFileSync(path.join(dir, "sk.events.jsonl"), "utf8") 68 + .trim().split("\n").map((l) => JSON.parse(l)); 69 + const exit = events.find((e) => e.type === "session_exit"); 70 + expect(exit.exitCode).toBe(137); 71 + expect(exit.signal).toBe(9); 72 + }, 20000); 73 + 74 + it("a clean exit is unchanged (raw code, no signal)", async () => { 75 + const dir = fs.mkdtempSync(path.join(testRoot, "d-")); 76 + createSession(dir, "ce", ["sh", "-c", "exit 5"]); 77 + 78 + const meta = await waitForExit(dir, "ce"); 79 + expect(meta.exitCode).toBe(5); 80 + expect(meta.signal).toBeUndefined(); 81 + 82 + const events = fs.readFileSync(path.join(dir, "ce.events.jsonl"), "utf8") 83 + .trim().split("\n").map((l) => JSON.parse(l)); 84 + const exit = events.find((e) => e.type === "session_exit"); 85 + expect(exit.exitCode).toBe(5); 86 + expect(exit.signal).toBeUndefined(); 87 + }, 20000); 88 + });