← Blog

setRawMode is not cfmakeraw

September 11, 2026

process.stdin.setRawMode(true) sits under almost every terminal UI written for Node, whether you call it or Ink does: keystrokes arrive one at a time and unechoed, Enter as \r, Ctrl-C as 0x03 instead of a signal. In Node the call goes to libuv's uv_tty_set_mode, and libuv decides what raw means. oam, a JavaScript and TypeScript runtime built on Rust and V8, has no libuv, so it had to decide too. The first attempt decided in one comment:

// cfmakeraw == Node's raw mode (libuv uses it)

That comment shipped in every release from v0.7.0 through v0.13.1, and it was wrong. It is the first of four raw-mode traps found in two days while getting one real TUI working under oam run. None of them needs oam: anything implementing raw mode without libuv can meet them. Each is checked below against libuv v1.51.0 (the version Node v22.22.2 vendors), Node's src/node.cc and, where it decides, the kernel.

cfmakeraw clears OPOST

Node's setRawMode(true) asks for UV_TTY_MODE_RAW_VT (src/tty_wrap.cc), which libuv folds into UV_TTY_MODE_RAW on Unix. That mode never calls cfmakeraw. It applies six lines to the terminal's termios, the flag set the kernel keeps for each terminal (src/unix/tty.c):

tmp.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
tmp.c_oflag |= (ONLCR);
tmp.c_cflag |= (CS8);
tmp.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
tmp.c_cc[VMIN] = 1;
tmp.c_cc[VTIME] = 0;

libuv keeps cfmakeraw for UV_TTY_MODE_IO, a mode Node never asks for, and the difference is on the output side: cfmakeraw clears OPOST. A bare \n then moves the cursor down without returning it, and a TUI that writes \r once per frame and \n between rows draws a staircase. libuv leaves OPOST as it found it and forces ONLCR, so \n reaches the terminal as \r\n. Measured on a WSL2 pty, kernel 6.6 on aarch64: row1\nrow2 arrives at the master unchanged from a cfmakeraw'd writer, and as row1\r\nrow2 under libuv's recipe. oam now applies the six lines, with libuv's TCSADRAIN, and an e2e test on a real pty pins the \r\n.

Go's x/term MakeRaw (term_unix.go), Python's tty.setraw (Lib/tty.py) and crossterm, through rustix's make_raw (src/terminal/sys/unix.rs), all clear OPOST. A Node TUI never had to write \r\n, so one ported onto any of them staircases every row: put OPOST | ONLCR back after going raw, or write \r\n. To check a running program, run stty -a against its terminal from another one (-F /dev/pts/N on Linux, -f /dev/ttysN on macOS): libuv's raw shows opost onlcr, and cfmakeraw's shows -opost.

A read already waiting keeps its line buffering

The second trap is Windows-only, and the user sees it at once: after a prompt, the first thing they type is invisible until they press Enter.

While stdin flows, a read is always outstanding, so once a readline prompt returns, the next read is already blocked in ReadConsoleW with ENABLE_LINE_INPUT set. setRawMode(true) flips the console mode, but that read keeps its line buffering; only its echo follows the new mode (measured on Windows 11's conhost 10.0.26100.1).

libuv's answer, uv__cancel_read_console (src/win/tty.c), runs before the switch. It writes a synthetic VK_RETURN into the console input, discards the line the pending read returns, and restarts the read under the new mode. The Enter lands on a read that is still cooked and echoes a newline, so libuv saves the cursor first and restores it — a row higher if the echo scrolled the last row. Type-ahead in the line buffer is lost.

oam injected the same Enter and lost the same type-ahead, but it injected after the switch — and copied the last-row adjustment anyway. Under the new mode that Enter is a bare carriage return, which scrolls nothing, so a prompt on the buffer's last row came back with the cursor a row too high. Measured in a fresh conhost: name? bob became MARK? bob. #125 puts it in libuv's order.

Copying the adjustment without its precondition was the deeper mistake. libuv steps up unconditionally, and that is safe only because libuv's own cooked mode always sets ENABLE_PROCESSED_INPUT — which is what decides whether that Enter writes \r\n or a bare \r. Echo has nothing to do with it: conhost writes the newline for any line-buffered read, echoing or not. Inherit a console without PROCESSED_INPUT and the step-up is wrong again, so oam now records what the Enter actually wrote.

No automated test reaches this path. The pseudoconsole harness hands its child a read-only console input handle, on which SetConsoleMode fails with access denied, so both tests are skipped (issue #109, open). The fix was verified by hand through node-pty on Windows 11: the old build had not delivered the first keystroke when the driver gave up, and the new one delivered each key before Enter.

Unix needs no cancel. When a tcsetattr other than TCSAFLUSH clears ICANON, the kernel's line editing, Linux's n_tty_set_termios and XNU's ttioctl_locked both make the pending input readable and wake the blocked read. oam's source said XNU does not wake; that misread bsd/kern/tty.c. Both wakes are read off the source; what is measured, on Linux 6.6 and macOS, is only that a key typed after the switch arrives without Enter.

An exit listener is not an exit path

A program that exits while raw has to put the terminal back, or its shell has no echo and no line editing, and the user types reset blind. oam's restore was a process.on('exit') listener, and four ways out of oam never emit 'exit': an EPIPE bail on stdout or stderr, the out-of-memory banner (exit 134), the CLI's own fatal returns, and the re-raise of a signal whose listener is gone.

Node registers atexit(ResetStdio) (src/node.cc), and on POSIX its SIGINT and SIGTERM handler calls ResetStdio() before re-raising (same file). That calls libuv's uv_tty_reset_mode, documented as “async signal-safe on Unix platforms”, then restores each stdio terminal's startup termios. So a Node TUI gets its terminal back with no handler of its own. A Go defer term.Restore or a Python finally covers none of os.Exit, os._exit or a fatal signal.

oam now arms a native exit hook on the first enable, and all four paths drain it. That still left the fifth: oam installed a signal handler only when JavaScript asked for one, so a SIGTERM to a raw program that never listened died at the default action with the terminal still raw. Going raw now arms SIGINT and SIGTERM for the process, and a delivery nobody is listening for restores the terminal and then dies by the signal.

Three things made that hard. Whether anyone is listening is a question about the process: deliveries are broadcast to every isolate, so a worker that went raw must not out-vote the main thread's listener. The answer has to outlive the run that armed it, or a later signal reaches a handler with nothing behind it and is silently discarded — a process Ctrl-C cannot kill. And a stop is not a death: SIGTSTP must leave raw mode alone and put the handler back afterwards.

One difference from Node stays, deliberately: the hook restores what the last enable found, as libuv's own setRawMode(false) does, rather than the startup termios.

Testing the restore: macOS writes a bit you did not

Two tests that compared the termios before and after a raw round trip, field by field, failed on the macOS release leg; on Linux, whose kernel never writes the bit, the same comparison passes.

XNU's ttioctl_locked sets PENDIN (“retype pending input”, in sys/termios.h) when a termios turning ICANON back on arrives through anything but TCSAFLUSH, which describes every Node-shaped restore. It then stores t->c_lflag | ISSET(tp->t_lflag, PENDIN), so no tcsetattr that leaves ICANON on can clear the bit; a flush, going raw again, or the terminal's next read, poll or incoming byte does. On macOS 26.6 the Node installed there (v22.23.1, which vendors the same libuv 1.51.0) leaves a termios identical to oam's in every field the harness compares, PENDIN and all.

TCSAFLUSH dodges the bit by flushing input first, which throws away whatever the user typed that the program had not read. Node never restores that way: setRawMode(false) uses TCSADRAIN and the exit path TCSANOW. So the strict assertion was one only a restore that diverged from Node could pass. oam's harness now masks PENDIN (0x20000000) out of c_lflag for a termios the runtime restored, and deliberately not for one nothing touched, where the bit turning up is the kernel's receipt that a switch happened. If you test a restore on macOS, mask it too.

The reference is libuv, at your Node's version

Raw mode is not a set of flags from the termios man page. It is libuv's contract, at the version your Node vendors: deps/uv/include/uv/version.h says 1.51.0 for v22.22.2. If you are writing raw mode yourself, read uv_tty_set_mode in src/unix/tty.c and src/win/tty.c, about sixty lines each, and uv__cancel_read_console beside the second, before the man page. The first two traps are handled there; the exit restore is ResetStdio in Node's src/node.cc, and the fourth trap is the kernel's.

On Windows oam deliberately edits the console mode it finds rather than writing libuv's fixed one, which drops mouse input, and any VT input a shell turned on, until the process exits normally. It is divergence 33 in docs/node-divergences.md.

oam is beta: breaking changes before 1.0 are still possible, and there is no LTS, so this is not the runtime for a service you are on call for. Two things above have no gate behind them — the Windows console path and the wake at tcsetattr — and each says so where it appears.