Reclaim the terminal · Nishant JoshiPipe a file into less and it still responds when you press j. But if less is reading the file from stdin, where is it reading your keystrokes from?
I ran into the same question while connecting piped input to an interactive program. By the time the interactive program started, fd 0 was an exhausted pipe. Tools like less and fzf showed that getting the keyboard back was possible: fzf reads its entire candidate list from a pipe and still lets you type to filter it. But I did not know how they did it. Here’s what I found.
The experiment
The program is one binary piped into itself four times:
./target/debug/feat-test | ./target/debug/feat-test | ./target/debug/feat-test | ./target/debug/feat-test<br>Each stage waits for its turn, reads one line from the terminal, and passes the accumulated lines downstream. The final stage prints all four lines with the pid of the process that read each one.
Before typing anything, run ps in another window: all four processes already exist. The shell does not launch stage 2 when stage 1 finishes. It launches the whole pipeline at once, and the final stage is alive and waiting before you have pressed a single key.
That leaves three questions. The processes are all instances of the same binary, so how does each one know whether it is first, last, or somewhere in the middle? After the first stage, stdin carries data from a pipe, so how does a process get back to the keyboard? And since all four are running from the start, what stops them from trying to read your input at once?
How does each process know where it is?
It is easy to picture a pipeline as having one stdin at the beginning and one stdout at the end. But stdin and stdout belong to processes, not pipelines. Every process has its own fd 0 and fd 1. The shell simply connects them to different things.
For the first process, fd 0 still points to the terminal. For every later process, it points to the previous stage’s pipe. At the other end, the final process’s fd 1 points to the terminal while every earlier process writes to a pipe.
Rust exposes the relevant check through IsTerminal:
let lines: VecEntry> = if stdin.is_terminal() {<br>// First stage: read from stdin, which is the terminal.<br>} else {<br>// Later stage: drain the pipe, then read from the terminal.<br>};
if stdout.is_terminal() {<br>// Last stage: print for the user.<br>} else {<br>// Earlier stage: serialize for the next process.<br>The binary does not need a stage number. It can infer its position from what its own stdin and stdout are connected to.
What tells the next stage to start?
I initially expected the stages to need a separate coordination channel. They do not. A downstream stage starts by draining its stdin:
let mut buf = String::new();<br>stdin.read_to_string(&mut buf)?;<br>At first, this looks like ordinary data loading. But read_to_string does not return just because the pipe is empty. An empty pipe means there is nothing to read yet; EOF means nothing can ever arrive again.
As long as stage 1 is alive, stage 2 waits inside that call. When stage 1 exits, its end of the pipe closes. Only then does stage 2’s read return. The pipe itself provides the handoff: there is no separate “your turn” message.
But stage 2 now has a different problem. It is finally awake, and fd 0 is an exhausted pipe. It still has no apparent way to reach the keyboard.
How does a piped process get the keyboard back?
After draining stdin, a downstream stage still has the exhausted pipe on fd 0. It opens its controlling terminal separately:
let term = File::open("/dev/tty")?;<br>This does not restore fd 0. It creates a new file descriptor, usually the next free slot, that refers to the same terminal. The program can read from that descriptor directly. There is no ceremony involved: no permission to request, no coordination with the shell. Any process with a controlling terminal can open it at any time.
This is where my mental model had been backwards. Your keystrokes never go “to stdin.” They go to the terminal, and fd 0 is just a descriptor that usually happens to point there. When the shell pointed fd 0 at a pipe instead, the keyboard did not go anywhere; the process only lost its usual pointer to it. Opening /dev/tty makes a new one. This is what less is doing when you press j: the file arrives on stdin, and your keystrokes come from the terminal.
/dev/tty does not name a particular device such as /dev/ttys003. It resolves to the controlling terminal of the calling process. The processes in this pipeline belong to the terminal session created by the shell, so the same path works for every stage.
Here is the central part of the program (full source at the bottom):
let lines: VecEntry> = if fd0.is_terminal() {<br>let mut buf = String::new();<br>fd0.read_line(&mut buf)?;<br>vec![Entry { pid: process::id(), data: buf }]<br>} else {<br>let mut buf = String::new();<br>fd0.read_to_string(&mut buf)?;<br>let mut lines: VecEntry> = serde_json::from_str(&buf)?;
let term =...