Skip to content

The TUI has no job-control handshake: once Codewhale's process group ends up in the background, SIGTTIN suspends it, the terminal keeps the TUI's mouse/paste/raw modes, and an in-flight turn exists only in the checkpoint #6169

Description

@Lujc0523

Summary

Codewhale's TUI performs a foreground-ownership check exactly once, at startup
(require_foreground_terminal_owner(), called from event_loop.rs:628). There is no
runtime equivalent and no SIGTSTP / SIGTTIN / SIGCONT handler anywhere in the TUI.
So if the TUI's process group becomes non-foreground after startup — by any job-control
route — the outcome is:

  1. The background group's next read of the TTY raises SIGTTIN. The whole process group is
    stopped by the kernel, in the middle of an agent turn, with no chance to run any
    cleanup. The shell reports:
    [1] + <pid> suspended (tty input) codewhale resume <session>
  2. Because no cleanup runs, every terminal mode the TUI enabled stays enabled: raw mode,
    ?1000h/?1002h/?1003h/?1006h/?1015h mouse reporting, ?2004h bracketed paste, the
    alternate screen. The terminal then "poisons the shell with raw escape reports" — the
    exact failure the code comment at tui/ui/terminal.rs:119-120 warns about — so mouse
    movement / scrolling feeds escape bytes into whatever foreground shell is present.
  3. Any child the TUI had just spawned is orphaned or left as a zombie, because the
    stopped parent can never wait() on it (zsh <defunct>, parent = the stopped TUI).
  4. The in-flight turn is not in the session file. The session JSON was last written at
    the previous turn boundary (249 messages); the work performed before the stop lives only
    in sessions/checkpoints/<id>.json (265 messages). Whether resume sees those 16
    messages depends on which of the two it reads.

The TUI is not crashed and not deadlocked — it is stopped, and fg revives it. But the
user sees a frozen/garbled terminal, a shell prompt, and no indication that the session is
still alive in the background. From the user's side this reads as "the agent died mid-run".

Environment

  • Codewhale 0.9.13 (npm wrapper + native binary built from the codewhale-cli 0.9.13 crate)
  • Linux, xfce4-terminal, PTY 271x49, TERM=xterm-256color
  • Long-lived session resumed in-session: codewhale resume <session-id>
  • Process state read directly from ps / /proc and the shell's own job notification

Observed sequence

The consequence is reproducible and fully evidenced below. The exact job-control action
that first moved the process group out of the foreground is not yet pinned down — the
kernel does not record why a process was stopped, and the shell's earlier suspended
notification (if any) had scrolled away. Candidates that all produce this end state:

  • Ctrl+Z followed by bg (resume in background) — the resumed group immediately reads the
    TTY and takes SIGTTIN
  • any tcsetpgrp that hands the foreground to another job while Codewhale keeps its input
    pump running
  • a second shell or launcher sharing the same controlling TTY

What is established: at the moment of the stop, the process group was not the TTY's
foreground group, and the TUI was reading the TTY (that is the only thing that raises
SIGTTIN).

Expected behavior

Either the TUI never ends up reading a TTY it does not own, or — when it does — it behaves
like every other full-screen terminal program (vim, nano, htop, less): on SIGTSTP/SIGTTIN
it restores the terminal (leave alt screen, disable mouse/paste, disable raw mode), stops
itself, and on SIGCONT re-enters its modes and repaints. The user sees a clean shell while
suspended and an intact TUI after fg.

Actual behavior

  • Whole process group T (stopped), wchan: do_signal_stop; shell shows
    suspended (tty input).
  • Terminal left in the TUI's modes; SGR/CUP escape fragments (35;37;18N...,
    51;59;26M...) appear appended to shell prompt lines — i.e. the terminal's report stream
    is being echoed by the foreground shell.
  • A child process spawned immediately before the stop is left as an unreaped zombie.
  • The session file is stale relative to the checkpoint by one full turn.

Evidence

Process state (captured after the incident, while still stopped):

$ ps -o pid,ppid,pgid,sid,tpgid,stat,tty,args -p <tui-pid>,<shell-pid>
    PID    PPID    PGID     SID   TPGID STAT TT       COMMAND
 <tui>  <shell>   <tui> <shell> <shell> Tl   pts/30   node …/codewhale resume <session>
 <bin>   <tui>    <tui> <shell> <shell> Tl   pts/30   …/downloads/codewhale resume <session>
<zomb>   <bin>   <zomb> <shell> <shell>  Z   pts/30   [zsh] <defunct>
 <shell> <login> <shell> <shell> <shell> Ss+  pts/30   zsh

TPGID is the shell, not the TUI: the TUI group is in the background. STAT is Tl.
/proc/<tui>/status reports State: T (stopped) with wchan: do_signal_stop.

Shell job notification (visible on screen):

[1]  + <tui-pid> suspended (tty input)  codewhale resume <session-id>

(tty input) is the shell's rendering of a SIGTTIN stop — distinct from a plain
Ctrl+Z stop, which zsh reports without the (tty input) suffix.

Zombie child: <zsh> Z, PPid = the TUI binary, started in the same second as the
stop and never reaped — consistent with the parent being stopped before it could wait().

Session data split across two files:

sessions/<session>.json             249 messages  mtime 18:21:05   (previous turn boundary)
sessions/checkpoints/<session>.json 265 messages  mtime 18:24:13   (last snapshot before stop)

The last message in the checkpoint is an assistant message carrying two bash tool calls
with no matching tool results
— the exact point of interruption. The 16 messages missing
from the session file are one complete round of work.

Terminal modes after the suspend (affected TTY vs. a healthy Codewhale TTY on the same
host, same terminal program):

affected: isig -icanon iexten -echo … opost
healthy : -isig -icanon -iexten -echo … -opost

The affected TTY still carries TUI-left signal/output processing bits. (Both are
-icanon -echo because zsh's ZLE also runs that way, so this comparison is suggestive, not
conclusive on its own.)

Screen text: the shell's own prompt lines are followed by long runs of 256-colour SGR /
cursor-position fragments, which is the "raw escape reports" pattern named in the source
comment quoted below.

Analysis

The codebase already documents this failure mode and defensively guards one moment —
startup:

// codewhale-tui/src/tui/ui/terminal.rs:115-126
/// Refuse to enter terminal modes from a background Unix process group.
///
/// A TTY can still report `isatty(3) == true` after a shell has suspended the
/// process. Reading from that background group triggers `SIGTTIN`; enabling
/// mouse or keyboard protocols before that stop poisons the shell with raw
/// escape reports. Check foreground ownership before the first mode change.
#[cfg(unix)]
pub(crate) fn require_foreground_terminal_owner() -> Result<()> {}

and the interactive-shell path is outright refused on Unix because it cannot take over the
terminal safely:

// codewhale-tui/src/tools/shell.rs:1700-1708  (Unix)
pub(crate) fn inherited_interactive_terminal_refusal() -> Option<&'static str> {
    Some("Inherited interactive terminal takeover is unavailable on Unix because foreground \
          TTY ownership cannot be transferred safely. …")
}

// codewhale-tui/src/tools/shell.rs:2127-2133
// A new Unix process group that inherits the terminal is not its foreground owner.
// Letting it read stdin triggers SIGTTIN; sharing Codewhale's group instead would
// make cooked-mode Ctrl+C terminate both parent and child. Until this path owns a
// complete POSIX job-control lease, fail closed before spawning.

Both guards are pre-spawn guards. Nothing covers the case where Codewhale's own group
loses the foreground while it is running:

  • require_foreground_terminal_owner() has exactly one caller — event_loop.rs:628, in the
    startup path. It is never re-evaluated, and there is no periodic or post-spawn
    tcgetpgrp(STDIN_FILENO) != getpgrp() check.
  • emergency_restore_terminal() (tui/ui/terminal.rs:618) exists and does the right thing
    (DisableMouseCapture, disable_raw_mode, …), but is only wired to the panic hook
    (lib.rs:1757-1763) and one early-startup path (lib.rs:767).
  • A grep for SIGTSTP|SIGTTIN|SIGCONT|signal_hook across codewhale-tui finds no job
    control handling at all
    — the only SIGWINCH mention is a comment in
    tui/external_editor.rs. So when the kernel stops the process, no handler runs, and every
    mode the TUI turned on stays on.

The same "terminal handoff is the caller's responsibility and one caller forgot" shape as
the external-editor input-pump issue (/hooks edit / Ctrl+Shift+O), but in the opposite
direction: there the TUI kept competing for input; here the TUI loses its claim on the
terminal entirely and the kernel does the stopping.

The split session state is the second half of the problem. persist_pending_work_checkpoint
(tui/ui/session_state.rs:80) writes PersistRequest::SaveCheckpoint → checkpoints/<id>.json
for in-progress work, while the main sessions/<id>.json is written at turn boundaries. A
stop that lands mid-turn therefore leaves the only copy of the turn in the checkpoint.

Impact

  • Silent loss of a turn's visibility. A stop mid-turn leaves the session file one turn
    behind. If the user gives up and kills the process, or resumes through a path that reads
    the session file rather than the checkpoint, that work disappears.
  • Terminal left poisoned. Mouse reports and bracketed-paste bytes are delivered into an
    ordinary shell. At minimum this spews escape garbage; at worst the shell accumulates
    content the user never typed.
  • Misleading symptom. The session is alive and fg-recoverable, but it is indistinguishable
    from a crash or a hang. The user's rational next step is to kill it — which is precisely the
    action that discards the unflushed turn.
  • Unreaped children. Any child alive at stop time becomes a zombie (or an orphan if the
    parent is later killed), so process-group signalling designed to clean up children has
    nothing to run against.
  • Reproducible on demand with any terminal editor or long-running shell child present at the
    moment of the stop; no special configuration is required.

Suggested fix

  1. Install a job-control handshake (the durable fix). Handle SIGTSTP, SIGTTIN, and
    SIGCONT: on stop, run the same teardown as emergency_restore_terminal(), then
    raise(SIGSTOP) (or re-raise the original signal with the default disposition restored);
    on SIGCONT, re-enable modes and force a full repaint. This is what every other
    full-screen TUI does, and it makes the failure impossible rather than merely detected.
  2. Re-check foreground ownership at runtime, not just at startup. After every child spawn
    and on a low-frequency timer, compare tcgetpgrp(STDIN_FILENO) with getpgrp(). On
    mismatch, pause the terminal input pump and surface an explicit message ("this session is
    in the background — run fg") instead of being stopped by the kernel with no diagnostics.
    Reuse validate_foreground_process_group so the message stays consistent with the startup
    one.
  3. Make the checkpoint the recovery source of truth. Have resume prefer the checkpoint
    when it is newer than the session file, and tell the user how many messages came from it.
    Alternatively, flush the in-flight turn into the session file when a stop or a
    child-spawn is detected.
  4. Extend cleanup coverage beyond the panic hook: SIGTERM/SIGHUP should also run
    emergency_restore_terminal() and reap children, so a killed session does not leave the
    terminal in the same state.

How to confirm you hit this (diagnostic recipe)

# 1. Is the TUI stopped rather than dead?  STAT `T` and a foreground pgid that is not yours.
ps -o pid,ppid,pgid,tpgid,stat,tty,args -p "$(pgrep -f 'codewhale resume' | head -1)"
cat /proc/<pid>/status | grep -E '^(State|SigPnd|ShdPnd)'

# 2. Unreaped children of the stopped TUI:
ps -eo pid,ppid,stat,args | awk '$3 ~ /^Z/'

# 3. Was the turn flushed? Compare message counts and mtimes.
ls -l --time-style=full-iso ~/.codewhale/sessions/<id>.json \
                       ~/.codewhale/sessions/checkpoints/<id>.json

# 4. Recover without losing the turn (do NOT kill):
#    in the terminal that owns the session ->  fg

Test gap

There is a unit test for the startup guard
(tui/ui.rs:360, tui_launch_preflight_rejects_background_process_group) but nothing
asserts runtime behaviour:

  • no test that the TUI survives a SIGTSTP/SIGTTIN/SIGCONT cycle with the terminal
    restored (termios compared before/after, and mouse/paste/alt-screen re-enabled on resume);
  • no test that emergency_restore_terminal() runs on anything other than the panic path;
  • no test that a checkpoint newer than the session file is what resume loads.

A PTY-level integration test in the style of the external-editor reproduction script would
cover all three: start a TUI on its own PTY, move its foreground group away, send SIGTTIN,
assert the process stops and that the PTY's modes were restored, then SIGCONT and assert
repaint.

Workaround

  • Recover the running session in place with fg in the owning terminal — the process is
    stopped, not dead, and the in-flight turn is still in memory.
  • If the session is abandoned, run reset (or stty sane) in that terminal to clear the
    leftover modes; otherwise mouse movement keeps feeding escape bytes into the shell.
  • Avoid backgrounding the Codewhale job (Ctrl+Z + bg) on a TTY you intend to keep using.

Reporter notes

  • No data was lost in the reported incident: the session file parses cleanly, all 149
    tool calls in the persisted portion are paired, and the 16 unflushed messages are intact in
    the checkpoint. Only the two tool results that were in flight are missing (both were
    read-only inspection commands, trivially re-runnable).
  • The stopped session was recoverable after the fact with fg; nothing in the TUI itself was
    deadlocked. The damage was to the terminal state and to session-file freshness.
  • Companion report: codewhale-issue-hooks-editor-input-contention.md/hooks edit hands
    the terminal to $EDITOR without pausing the TUI input pump, so keystrokes are split
    between two readers. That bug and this one are mirror images: there the TUI keeps a claim
    on the terminal it should have released, here it loses a claim it should have kept. Both
    follow from terminal ownership being implemented per call-site rather than as one enforced
    invariant, and each report now cross-references the other.
  • A screen capture of the affected terminal is available on request: it shows the frozen TUI
    frame, the shell's suspended (tty input) job notification, and the escape fragments
    appended to the shell prompt.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions