···7070| `focus_request` | — |
7171| `cursor_visible` | — |
7272| `session_start` | `tags?` |
7373-| `session_exit` | `exitCode` |
7373+| `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 |
7474| `session_exec` | `previousCommand, command` |
7575| `session_respawn` | — (`pty gc` respawned a `strategy=permanent` session) |
7676| `session_abandoned` | `reason: "cwd-gone" \| "idle", idleDays?` — (`pty gc` reaped a live permanent session detected as abandoned) |
+9-1
src/events.ts
···68686969export interface SessionExitEvent extends EventBase {
7070 type: "session_exit";
7171+ /** The child's exit status. A signal death (e.g. OOM SIGKILL) is surfaced as
7272+ * 128 + signal (SIGKILL 9 → 137), matching shell `$?` semantics, so a
7373+ * consumer gating on "nonzero" catches it. */
7174 exitCode: number;
7575+ /** The raw signal number when the child was killed by a signal (absent on a
7676+ * normal exit). `exitCode` already encodes it as 128 + signal. */
7777+ signal?: number;
7278}
73797480export interface SessionExecEvent extends EventBase {
···503509 return `${prefix} started${tagStr}`;
504510 }
505511 case "session_exit":
506506- return `${prefix} exited (code ${event.exitCode})`;
512512+ return event.signal
513513+ ? `${prefix} killed by signal ${event.signal} (code ${event.exitCode})`
514514+ : `${prefix} exited (code ${event.exitCode})`;
507515 case "session_exec":
508516 return `${prefix} exec ${event.command} (was ${event.previousCommand})`;
509517 case "session_respawn":
+15-6
src/server.ts
···498498 }
499499 });
500500501501- this.ptyProcess.onExit(({ exitCode }) => {
501501+ this.ptyProcess.onExit(({ exitCode, signal }) => {
502502 this.exited = true;
503503- this.exitCode = exitCode;
504504- this.broadcast(encodeExit(exitCode));
505505- this.emitEvent(EventType.SESSION_EXIT, { exitCode });
503503+ // A signal death (e.g. an OS OOM SIGKILL) arrives from node-pty with a
504504+ // nonzero `signal` and often exitCode 0 — if we recorded only the raw
505505+ // exitCode, a killed process would look like a clean finish and any
506506+ // consumer gating on "nonzero exit" (convoy's crash→ding) would miss it.
507507+ // Surface it the way a shell does: 128 + signal (SIGKILL 9 → 137).
508508+ const code = signal ? 128 + signal : exitCode;
509509+ this.exitCode = code;
510510+ this.broadcast(encodeExit(code));
511511+ this.emitEvent(EventType.SESSION_EXIT, {
512512+ exitCode: code,
513513+ ...(signal ? { signal } : {}),
514514+ });
506515 // Save exit status immediately so the session shows as "exited"
507516 // in pty list during the cleanup window. lastLines may be incomplete
508517 // here since PTY data could still be in-flight — close() will
509518 // update with the final output.
510510- this.saveExitMetadata(exitCode);
519519+ this.saveExitMetadata(code);
511520 this.resolveChildExited();
512512- options.onExit?.(exitCode);
521521+ options.onExit?.(code);
513522 });
514523515524 // Create Unix socket server
+88
tests/exit-signal.test.ts
···11+import { describe, it, expect, afterAll } from "vitest";
22+import * as fs from "node:fs";
33+import * as os from "node:os";
44+import * as path from "node:path";
55+import { fileURLToPath } from "node:url";
66+import { spawnSync, execFileSync } from "node:child_process";
77+88+const __dirname = path.dirname(fileURLToPath(import.meta.url));
99+const nodeBin = process.execPath;
1010+const cliPath = path.join(__dirname, "..", "dist", "cli.js");
1111+const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-sig-"));
1212+const bgPids: number[] = [];
1313+afterAll(() => {
1414+ for (const pid of bgPids) { try { process.kill(pid, "SIGKILL"); } catch {} }
1515+ fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1616+});
1717+1818+function runCli(dir: string, args: string[]) {
1919+ return spawnSync(nodeBin, [cliPath, ...args], {
2020+ env: { ...process.env, PTY_SESSION_DIR: dir, PTY_ROOT_LEGACY_SILENT: "1" },
2121+ encoding: "utf8", timeout: 15000,
2222+ });
2323+}
2424+2525+function createSession(dir: string, name: string, cmd: string[]): number {
2626+ expect(runCli(dir, ["run", "-d", "--id", name, "--", ...cmd]).status).toBe(0);
2727+ const pid = Number(fs.readFileSync(path.join(dir, `${name}.pid`), "utf8").trim());
2828+ bgPids.push(pid);
2929+ return pid;
3030+}
3131+3232+function readMeta(dir: string, name: string): any {
3333+ return JSON.parse(fs.readFileSync(path.join(dir, `${name}.json`), "utf8"));
3434+}
3535+3636+async function waitForExit(dir: string, name: string): Promise<any> {
3737+ const start = Date.now();
3838+ while (Date.now() - start < 8000) {
3939+ try {
4040+ const m = readMeta(dir, name);
4141+ if (m.exitedAt) return m;
4242+ } catch {}
4343+ await new Promise((r) => setTimeout(r, 50));
4444+ }
4545+ throw new Error(`session "${name}" never recorded an exit`);
4646+}
4747+4848+describe("pty surfaces a signal death (OOM SIGKILL) instead of losing it", () => {
4949+ it("a SIGKILL'd child is recorded as 128+signal (137), not exit 0", async () => {
5050+ const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
5151+ const daemonPid = createSession(dir, "sk", ["sh", "-c", "exec sleep 300"]);
5252+5353+ // The session's leaf is the daemon's direct child (exec replaces the sh).
5454+ const leaf = Number(
5555+ execFileSync("pgrep", ["-P", String(daemonPid)], { encoding: "utf8" }).trim().split("\n")[0],
5656+ );
5757+ expect(Number.isInteger(leaf)).toBe(true);
5858+5959+ process.kill(leaf, "SIGKILL"); // simulate an OS OOM kill
6060+6161+ const meta = await waitForExit(dir, "sk");
6262+ // Was exit 0 (clean finish) before the fix — a real crash that convoy's
6363+ // nonzero gate would have missed. Now surfaced as 128 + SIGKILL(9) = 137.
6464+ expect(meta.exitCode).toBe(137);
6565+6666+ // The raw signal is also surfaced on the session_exit event.
6767+ const events = fs.readFileSync(path.join(dir, "sk.events.jsonl"), "utf8")
6868+ .trim().split("\n").map((l) => JSON.parse(l));
6969+ const exit = events.find((e) => e.type === "session_exit");
7070+ expect(exit.exitCode).toBe(137);
7171+ expect(exit.signal).toBe(9);
7272+ }, 20000);
7373+7474+ it("a clean exit is unchanged (raw code, no signal)", async () => {
7575+ const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
7676+ createSession(dir, "ce", ["sh", "-c", "exit 5"]);
7777+7878+ const meta = await waitForExit(dir, "ce");
7979+ expect(meta.exitCode).toBe(5);
8080+ expect(meta.signal).toBeUndefined();
8181+8282+ const events = fs.readFileSync(path.join(dir, "ce.events.jsonl"), "utf8")
8383+ .trim().split("\n").map((l) => JSON.parse(l));
8484+ const exit = events.find((e) => e.type === "session_exit");
8585+ expect(exit.exitCode).toBe(5);
8686+ expect(exit.signal).toBeUndefined();
8787+ }, 20000);
8888+});