Skip to content

Fix: restore terminal input modes when the process is continued (SIGC… - #989

Closed
HermannBjorgvin wants to merge 1 commit into
vadimdemedes:masterfrom
HermannBjorgvin:fix/restore-raw-mode-on-sigcont
Closed

Fix: restore terminal input modes when the process is continued (SIGC…#989
HermannBjorgvin wants to merge 1 commit into
vadimdemedes:masterfrom
HermannBjorgvin:fix/restore-raw-mode-on-sigcont

Conversation

@HermannBjorgvin

Copy link
Copy Markdown

Fix: restore terminal input modes when the process is continued (SIGCONT)

Written by me (the human author)

Authored this PR with the help of Claude Code using Fable on medium effort. I did a red/green repro first of the actual problem I encountered in real life and then applied the fix from Fable.

After I did a few human QA passes on the fix I got an adverserial review from a subagent, and after that another review asking it to review it based on the maintainer's perspective which seemed to give a good result.

This solves an actual problem for me where I want to be able to stop a job with kill -STOP and resume it later without the raw mode bugging out on me.

Repro steps:

  1. Start app.js with node
  2. In another window take the pid given by app.js and run kill -STOP 123456
  3. Go back to the app.js window, there are still escape sequences but running fg should bring up app.js again with the raw input mode working still.

Repro application code:

import React, { useState, useEffect } from "react";
import { render, Box, Text, useInput, useStdout } from "ink";

// SGR mouse tracking is enabled manually — ink has no mouse API, but any
// raw-mode TUI that consumes mouse input does exactly this.
function App() {
  const [eventCount, setEventCount] = useState(0);
  const [lastInput, setLastInput] = useState("");
  const { stdout } = useStdout();

  useEffect(() => {
    stdout.write("\u001B[?1002;1006h");
    return () => {
      stdout.write("\u001B[?1002;1006l");
    };
  }, [stdout]);

  useInput((input) => {
    setEventCount((count) => count + 1);
    // hex so raw escape bytes never appear on screen (the harness asserts
    // on-screen escape sequences only ever come from tty echo)
    setLastInput(Buffer.from(input).toString("hex"));
  });

  return React.createElement(
    Box,
    { flexDirection: "column", borderStyle: "round", paddingX: 1 },
    React.createElement(Text, null, `pid: ${process.pid}`),
    React.createElement(Text, null, `input events: ${eventCount}`),
    React.createElement(Text, null, `last input: ${lastInput}`),
  );
}

render(React.createElement(App));

Problem (rest of the description written by Claude)

When an Ink app is stopped while running under a job-control shell — kill -STOP <pid>, or kill -TSTP <pid> sent externally — the shell notices the foreground job stopped, reclaims the terminal, and resets the tty to cooked mode (echo on, canonical input) so the user can type at the prompt.

When the process is later continued, nothing restores Ink's terminal state. Ink still believes raw mode is enabled, but the kernel is echoing every byte: keystrokes and mouse escape sequences (^[[<0;5;5M …) print directly on screen, and useInput receives nothing. Crucially, fg does not recover either — the app is stuck in this broken state until restart. There is no reasonable app-level workaround: re-asserting raw mode requires defeating libuv's tty-mode cache, which isn't reachable through Ink's public API.

Raw mode is state Ink owns, so re-asserting it when the kernel/shell clobbers it behind Ink's back belongs in Ink — the same way vim, less, and Node's readline re-assert terminal state on resume.

Solution

A process-level SIGCONT listener that reinstates whatever input state the app still owns, then repaints:

  • src/components/App.tsx — new restoreInputState(), registered through the existing onRegisterInputControl channel alongside pauseInput/resumeInput. Unlike those (which serve suspendTerminal()'s deliberate teardown), it works off the live ref counts: nothing was torn down, the terminal was simply taken away. If components still own raw mode it toggles setRawMode(false)(true) — the toggle is required because libuv caches the tty mode and treats a repeated enable as a no-op. Re-asserts bracketed paste if usePaste hooks are mounted. The toggle is try/catch-guarded: a tty fd gone bad while stopped must not crash the process from a signal handler.
  • src/ink.tsx — a single module-level SIGCONT handler shared by all interactive instances (a Set registry, installed on first subscribe, removed on last), so instance count never scales process listeners — mirroring how signal-exit multiplexes exit handling. Per instance, handleContinue no-ops while unmounted/unmounting or while suspendTerminal() has intentionally handed the terminal to a child. Otherwise it restores input state, settles pending throttled writes, pops-then-re-pushes the kitty keyboard flags, and forces a full repaint over whatever the shell drew (job status lines, prompt, echoed input).
  • src/log-update.tsreset() now also clears the hidden-cursor flag, so the repaint re-hides a cursor the shell prompt made visible while the process was stopped. Its only other caller (endSuspend) reaches reset() after log.done() already cleared the flag, so behavior there is unchanged.

Notable details

  • Kitty flags are popped before re-pushing. Being stopped never pops the terminal-side stack (unlike the suspendTerminal() path, where beginSuspend pops first). A bare push would grow the stack by one entry per stop/continue cycle while unmount pops only once, leaving the user's shell receiving CSI-u sequences after exit. Popping an empty stack is defined as a no-op, so pop-then-push is safe in all cases.
  • Windows guard. SIGCONT does not exist on Windows; the listener is only installed when process.platform !== 'win32', and the tests skip themselves there.
  • Background-continue semantics are preserved, not fought. If the process is continued in a background process group (kill -CONT without fg), the tcsetattr raises SIGTTOU and the job stops again — standard job control. When the job is foregrounded with fg, the shell delivers SIGCONT again and the handler completes the restore. The alternative — ignoring SIGTTOU and forcing the tty from the background — would put the user's shell into raw mode while they type at it, which is strictly worse.
  • Prior art, framed precisely: Node's readline re-enables raw mode on SIGCONT, but via a transient process.once paired with its Ctrl-Z/SIGTSTP handling. This listener is persistent because SIGSTOP is uncatchable — there is no "suspend moment" to hook, which is also why the cache-defeating toggle is needed where readline needs none.

Scope

This fixes externally delivered SIGSTOP/SIGTSTP. It does not make Ctrl-Z suspend work: under raw mode ISIG is off, so ^Z arrives at useInput as \x1a and never becomes a signal. Readline-style SIGTSTP handling (cooked mode, re-raise, re-arm on continue) is the symmetric other half of this feature and is proposed as a follow-up issue:

Tests

test/sigcont.tsx, five tests using the existing createStdout/createStdin helpers and process.emit('SIGCONT'):

  1. raw mode owned by useInput is reinstated via the [true, false, true] toggle (the toggle is the contract with libuv's cache, hence asserting the exact sequence);
  2. bracketed paste, kitty pop-before-push ordering, cursor re-hide, and the repainted frame all appear in the post-SIGCONT writes;
  3. no raw mode is enabled when nothing owns it;
  4. the listener is removed on unmount;
  5. multiple instances share a single process-level listener (count stays at +1, drops to baseline after the last unmount).

Manual QA

Verified in a real terminal (Ghostty, zsh, Linux) with a minimal repro app (useInput + SGR mouse tracking), in both directions — the bug on vanilla v7.1.1, and the fix on this branch:

Sequence v7.1.1 this branch
kill -STOPfg broken: cooked mode, input echoes, useInput dead recovers fully
kill -STOPkill -CONTfg broken even after fg recovers fully
kill -STOPkill -CONT (no fg) broken + escape codes leak into prompt job waits stopped (SIGTTOU); leak until fg — kernel semantics, see above
sequenceDiagram
    participant U as User (Ghostty)
    participant S as zsh (job control)
    participant K as Kernel (tty)
    participant A as Ink app

    U->>A: node app.js
    A->>K: setRawMode(true), enable mouse tracking
    Note over A: mouse events counted, nothing echoes

    U->>A: kill -STOP
    S->>K: reclaims tty, restores cooked mode
    Note over K: echo on, icanon on — app frozen, believes raw mode is on

    U->>A: kill -CONT
    A->>K: SIGCONT handler: setRawMode toggle (tcsetattr)
    K-->>A: SIGTTOU (background process group) → job stops again
    Note over S: mouse escape codes echo at the prompt until fg

    U->>S: fg
    S->>K: tcsetpgrp(app), sends SIGCONT
    A->>K: handler re-runs in foreground: raw mode + bracketed paste restored
    A->>U: kitty flags re-pushed, cursor re-hidden, full repaint
    Note over U: mouse events consumed again, nothing echoes
Loading

The same sequence is automated in a tmux-based harness (interactive shell in a pty, SGR mouse bytes injected via send-keys -H, termios asserted with stty -F <pane-tty>, screen scraped with capture-pane): red on v7.1.1, green on this branch, under both zsh and bash.

Full suite: 1058 tests pass (4 pre-existing known failures), typecheck and xo clean.

…ONT)

When an Ink app is stopped (SIGSTOP, or SIGTSTP sent externally) while
running under a job-control shell, the shell reclaims the terminal and
resets it to cooked mode. On resume Ink believed raw mode was still
enabled, so keystrokes and mouse escape sequences echoed directly on
screen and useInput received nothing — even after `fg`, with no way for
the app to recover.

A single shared process-level SIGCONT listener now reinstates the input
modes the app still owns (raw mode via a setRawMode toggle, since libuv
caches the tty mode and treats a repeated enable as a no-op; bracketed
paste), pops and re-pushes the kitty keyboard flags, re-hides the
cursor, and forces a full repaint over whatever the shell drew.

If the process is continued in a background process group, the
tcsetattr raises SIGTTOU and stops the job again — standard job-control
semantics — and the handler completes the restore when the job is
foregrounded with `fg`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sindresorhus

Copy link
Copy Markdown
Collaborator

I don't think we should add this to Ink. It introduces process-global signal and terminal lifecycle handling for a rare external SIGSTOP workflow, while normal Ctrl+Z remains unsupported. It also redraws from a background SIGCONT when no raw mode is owned, which can corrupt the foreground shell, and the repaint loses an active useCursor position. I think externally delivered STOP/CONT should remain unsupported, with suspendTerminal() as the explicit supported handoff.

@HermannBjorgvin

Copy link
Copy Markdown
Author

@sindresorhus thanks for the consideration and thoughtful review, much appreciated! Question: why is the normal CTRL+Z suspend is not planned to be supported? Any particular philosophy behind it aside from it not being a very common use case?

@sindresorhus

Copy link
Copy Markdown
Collaborator

No deeper philosophy. Ink uses raw mode, so Ctrl+Z is app input rather than a terminal-generated SIGTSTP. Supporting it in core would mean reserving that input and taking ownership of process-wide job control and terminal lifecycle. I think that should remain opt-in at the application level, using useInput and suspendTerminal(), rather than implicit Ink behavior.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants