fix(coding-agent): switched ctrl-z handler to SIGSTOP-self to defeat brush tokio SIGTSTP hijack

brush-core's Process::wait calls tokio::signal::unix::signal(SIGTSTP) to
detect when its children get stopped. Per tokio's documented contract,
the first call for a SignalKind permanently replaces the kernel-default
handler for the lifetime of the process. So once omp has executed any
bash tool call — even /usr/bin/true — SIGTSTP's default "stop" action
is gone, and InputController.handleCtrlZ's process.kill(0, "SIGTSTP")
gets swallowed by tokio. The TUI tore down via ui.stop() but the process
kept running in Sl+ state, leaving the user with a dead terminal that
only kill -9 could recover.

Send SIGSTOP to our own PID instead. SIGSTOP can't be caught, blocked,
or ignored — it stops the process at the kernel regardless of installed
handlers. Targeting self (not pgid=0) also leaves long-lived children
(MCP stdio servers, the persistent brush native shell) running across
the suspend, so they no longer freeze mid-IPC during a quick fg/bg
detour.

Fixes #3461
This commit is contained in:
roboomp
2026-06-25 11:33:35 +00:00
parent 451af61280
commit 8506fbdf52
4 changed files with 106 additions and 19 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed Ctrl+Z hanging the terminal after any tool call had run: the TUI tore down (`ui.stop()`) but the process kept running in `Sl+` state, leaving the user with a dead terminal recoverable only via `kill -9`. The embedded `brush-core` shell behind every bash tool call installs a tokio SIGTSTP listener on `Process::wait` (`crates/brush-core-vendored/src/sys/unix/signal.rs::tstp_signal_listener` → `tokio::signal::unix::signal(SIGTSTP)`); per tokio's contract, the first call for a SignalKind permanently replaces the kernel-default handler for the lifetime of the process. So the first bash invocation — even `/usr/bin/true` — silently overrode SIGTSTP's "stop" default, and `InputController.handleCtrlZ`'s subsequent `process.kill(0, "SIGTSTP")` was swallowed by tokio. The handler now sends `SIGSTOP` (uncatchable, unblockable, unignorable) to its own PID instead. Targeting self also leaves long-lived children (MCP stdio servers, the persistent brush native shell) running across the suspend — they're no longer frozen mid-IPC during a quick fg/bg detour ([#3461](https://github.com/can1357/oh-my-pi/issues/3461)).
## [16.1.19] - 2026-06-25
### Fixed
@@ -902,19 +902,19 @@ export class InputController {
}
handleCtrlZ(): void {
// SIGTSTP is POSIX job-control: Windows has no equivalent and
// `process.kill(_, "SIGTSTP")` throws `TypeError: Unknown signal:
// SIGTSTP` there, taking the whole agent down via an uncaught
// exception (issue #2036). No-op on platforms that cannot suspend.
// Job-control suspend is POSIX-only: on Windows `process.kill(_, "SIGSTOP")`
// throws `TypeError: Unknown signal: SIGSTOP` and takes the whole agent down
// via an uncaught exception (issue #2036, originally for SIGTSTP — same
// shape for SIGSTOP). No-op on platforms that cannot suspend.
if (process.platform === "win32") {
this.ctx.showStatus("Suspend (Ctrl+Z) is not supported on this platform");
return;
}
// Capture the listener so we can detach it if the signal never
// fires; otherwise a failed suspend would leave a stale SIGCONT
// handler that fires on the next unrelated continue and tries to
// re-`start()` an already-running TUI.
// Capture the listener so we can detach it if the signal never fires;
// otherwise a failed suspend would leave a stale SIGCONT handler that
// fires on the next unrelated continue and tries to re-`start()` an
// already-running TUI.
const onResume = (): void => {
this.ctx.ui.start();
this.ctx.ui.requestRender(true);
@@ -926,14 +926,35 @@ export class InputController {
this.ctx.ui.stop();
try {
// pid=0 → entire foreground process group; the shell receives
// SIGTSTP and parks the job.
process.kill(0, "SIGTSTP");
// SIGSTOP — not SIGTSTP — and to our own PID, not the foreground process
// group. Two reasons:
//
// 1. brush-core (the embedded shell behind every bash tool call) installs
// a tokio SIGTSTP listener on `Process::wait` to detect when its
// children have been stopped (`crates/brush-core-vendored/src/sys/
// unix/signal.rs::tstp_signal_listener` → `tokio::signal::unix::
// signal(SIGTSTP)`). Per tokio's documented contract, the first call
// for a given SignalKind permanently replaces the kernel-default
// handler for the lifetime of the process. So once the user has
// issued even one bash command — e.g. `/usr/bin/true` — SIGTSTP no
// longer stops omp: tokio swallows it and the TUI ends up torn down
// while the process keeps running with no live terminal (issue
// [#3461]).
// SIGSTOP cannot be caught, blocked, or ignored — it stops the
// process at the kernel regardless of what handlers are installed.
//
// 2. Targeting our own PID instead of pgid=0 leaves long-lived children
// (MCP stdio servers, the persistent brush native shell) running
// while the agent is parked. Real terminal Ctrl+Z reaches the whole
// foreground group only because the kernel delivers it there; an
// internally-generated suspend has no reason to freeze the agent's
// IPC partners mid-request, and the parent shell still detects
// WIFSTOPPED via SIGCHLD and parks the job.
process.kill(process.pid, "SIGSTOP");
} catch (err) {
// Either the runtime refused the signal or the kernel rejected
// it (some sandboxes block sending to pid=0). Tear the resume
// hook down and bring the TUI back so the user is not stranded
// on a frozen prompt.
// The runtime refused the signal (e.g. seccomp filter blocks SIGSTOP
// delivery to self). Tear the resume hook down and bring the TUI back
// so the user is not stranded on a frozen prompt.
process.removeListener("SIGCONT", onResume);
this.ctx.ui.start();
this.ctx.ui.requestRender(true);
@@ -44,7 +44,7 @@ afterEach(() => {
});
describe("InputController.handleCtrlZ", () => {
it("no-ops on Windows so the unsupported SIGTSTP signal can't crash the process (#2036)", () => {
it("no-ops on Windows so the unsupported SIGSTOP signal can't crash the process (#2036)", () => {
setPlatform("win32");
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
throw new Error("process.kill must not be called on win32");
@@ -64,7 +64,7 @@ describe("InputController.handleCtrlZ", () => {
expect(showError).not.toHaveBeenCalled();
});
it("sends SIGTSTP to the process group and registers a SIGCONT resume hook on POSIX", () => {
it("SIGSTOPs our own PID and registers a SIGCONT resume hook on POSIX (#3461)", () => {
setPlatform("linux");
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
const onceSpy = vi.spyOn(process, "once");
@@ -83,7 +83,7 @@ describe("InputController.handleCtrlZ", () => {
expect(stopOrder).toBeLessThan(killOrder);
expect(killSpy).toHaveBeenCalledTimes(1);
expect(killSpy).toHaveBeenCalledWith(0, "SIGTSTP");
expect(killSpy).toHaveBeenCalledWith(process.pid, "SIGSTOP");
expect(ui.start).not.toHaveBeenCalled();
expect(showError).not.toHaveBeenCalled();
@@ -98,7 +98,7 @@ describe("InputController.handleCtrlZ", () => {
it("restores the TUI and drops the SIGCONT listener when process.kill rejects the signal", () => {
setPlatform("linux");
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
throw new Error("Unknown signal: SIGTSTP");
throw new Error("Unknown signal: SIGSTOP");
});
const onceSpy = vi.spyOn(process, "once");
const removeSpy = vi.spyOn(process, "removeListener");
@@ -0,0 +1,62 @@
import * as path from "node:path";
import { describe, expect, it } from "bun:test";
/**
* Regression for https://github.com/can1357/oh-my-pi/issues/3461
*
* Ctrl+Z stopped working after any tool call: the TUI tore down but the
* process kept running (`Sl+`, not `T`), wedging the terminal until
* `kill -9`. Root cause: brush-core's `Process::wait` calls
* `tokio::signal::unix::signal(SIGTSTP)` to detect when its children get
* stopped. Per tokio's documented contract the first call for a SignalKind
* permanently replaces the kernel-default handler — so after the first
* `Shell::run` the parent's SIGTSTP no longer triggers the kernel STOP
* action, and `process.kill(0, "SIGTSTP")` from the Ctrl+Z handler became
* a no-op.
*
* The fix sends SIGSTOP (uncatchable) to our own PID instead. The unit
* test in `input-controller-suspend.test.ts` covers the JS handler's
* call shape; this file pins the runtime contract on the brush side so
* a brush upgrade or refactor that removes / gates the SIGTSTP listener
* forces a deliberate revisit of `handleCtrlZ` (we could go back to
* `process.kill(0, "SIGTSTP")` once the hijack is gone) instead of
* silently regressing behavior.
*/
describe("issue #3461 — Ctrl+Z hangs after a command has been run", () => {
const packageDir = path.resolve(import.meta.dir, "..");
const brushUnixSignal = path.resolve(
packageDir,
"../../crates/brush-core-vendored/src/sys/unix/signal.rs",
);
const brushProcesses = path.resolve(
packageDir,
"../../crates/brush-core-vendored/src/processes.rs",
);
const inputController = path.resolve(
packageDir,
"src/modes/controllers/input-controller.ts",
);
it("brush-core installs a tokio SIGTSTP listener on every Process::wait", async () => {
const signalSrc = await Bun.file(brushUnixSignal).text();
expect(signalSrc).toContain("tstp_signal_listener");
expect(signalSrc).toContain("tokio::signal::unix::signal");
// Pin the SIGTSTP constant specifically. A move to a non-job-control
// signal would invalidate the assumption this fix is built on.
expect(signalSrc).toMatch(/nix::libc::SIGTSTP/);
const processesSrc = await Bun.file(brushProcesses).text();
expect(processesSrc).toContain("tstp_signal_listener");
});
it("handleCtrlZ sends SIGSTOP — not SIGTSTP — to our own PID, defeating the brush hijack", async () => {
const src = await Bun.file(inputController).text();
// `process.kill(0, "SIGTSTP")` is the broken call shape; it must not
// reappear in the Ctrl+Z handler.
expect(src).not.toMatch(/process\.kill\(\s*0\s*,\s*["']SIGTSTP["']\s*\)/);
// SIGSTOP to our own PID is the only correct shape: SIGSTOP cannot be
// caught/blocked/ignored, and targeting self leaves child MCP / native
// shell processes running across the suspend.
expect(src).toMatch(/process\.kill\(\s*process\.pid\s*,\s*["']SIGSTOP["']\s*\)/);
});
});