Fix: restore terminal input modes when the process is continued (SIGC… - #989
Fix: restore terminal input modes when the process is continued (SIGC…#989HermannBjorgvin wants to merge 1 commit into
Conversation
…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>
|
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 |
|
@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? |
|
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. |
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 -STOPand resume it later without the raw mode bugging out on me.Repro steps:
kill -STOP 123456fgshould bring up app.js again with the raw input mode working still.Repro application code:
Problem (rest of the description written by Claude)
When an Ink app is stopped while running under a job-control shell —
kill -STOP <pid>, orkill -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, anduseInputreceives nothing. Crucially,fgdoes 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
SIGCONTlistener that reinstates whatever input state the app still owns, then repaints:src/components/App.tsx— newrestoreInputState(), registered through the existingonRegisterInputControlchannel alongsidepauseInput/resumeInput. Unlike those (which servesuspendTerminal()'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 togglessetRawMode(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 ifusePastehooks 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-levelSIGCONThandler shared by all interactive instances (aSetregistry, installed on first subscribe, removed on last), so instance count never scalesprocesslisteners — mirroring how signal-exit multiplexes exit handling. Per instance,handleContinueno-ops while unmounted/unmounting or whilesuspendTerminal()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.ts—reset()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) reachesreset()afterlog.done()already cleared the flag, so behavior there is unchanged.Notable details
suspendTerminal()path, wherebeginSuspendpops 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.SIGCONTdoes not exist on Windows; the listener is only installed whenprocess.platform !== 'win32', and the tests skip themselves there.kill -CONTwithoutfg), thetcsetattrraisesSIGTTOUand the job stops again — standard job control. When the job is foregrounded withfg, the shell deliversSIGCONTagain and the handler completes the restore. The alternative — ignoringSIGTTOUand forcing the tty from the background — would put the user's shell into raw mode while they type at it, which is strictly worse.SIGCONT, but via a transientprocess.oncepaired with its Ctrl-Z/SIGTSTP handling. This listener is persistent becauseSIGSTOPis 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^Zarrives atuseInputas\x1aand 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 existingcreateStdout/createStdinhelpers andprocess.emit('SIGCONT'):useInputis reinstated via the[true, false, true]toggle (the toggle is the contract with libuv's cache, hence asserting the exact sequence);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:kill -STOP→fguseInputdeadkill -STOP→kill -CONT→fgfgkill -STOP→kill -CONT(nofg)fg— kernel semantics, see abovesequenceDiagram 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 echoesThe same sequence is automated in a tmux-based harness (interactive shell in a pty, SGR mouse bytes injected via
send-keys -H, termios asserted withstty -F <pane-tty>, screen scraped withcapture-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.