DEV Community

Cover image for From | to SIGINT : How Linux Shells Build and Control Pipelines
Vishal Pandey
Vishal Pandey

Posted on Fully Autonomous

From | to SIGINT : How Linux Shells Build and Control Pipelines

Have you ever looked at a command like this and wondered what the | is actually doing?

cat file.txt | grep error
Enter fullscreen mode Exit fullscreen mode

We type pipelines so often that the symbol almost disappears. cat produces some text, grep filters it, and the terminal prints the result. Simple enough.

Linux has to build that connection before either program can do its work.

There is the shell, one process running cat, and another running grep. The two commands have different process IDs. A kernel pipe carries bytes between them, and a process group lets the shell control them as one job.

The | character never reaches cat or grep. Bash interprets it first. It creates the pipe, starts the commands, changes their standard file descriptors, groups the processes, and waits.

A tiny character is hiding a fair amount of operating-system machinery.

How Bash connects two processes through a kernel pipe and places them in one foreground process group

The finished pipeline

Start with the state Bash is trying to create.

cat and grep remain ordinary programs. cat writes to standard output. grep reads from standard input. Bash changes where those descriptors lead before either program begins running.

Two separate ideas are involved:

Pipe          moves bytes between processes
Process group lets the shell and terminal control related processes together
Enter fullscreen mode Exit fullscreen mode

Keeping those ideas separate makes the rest much easier to follow.

Bash creates a kernel pipe

For a simple two-command pipeline, Bash needs one pipe. Conceptually, it asks the kernel for one using pipe() or pipe2().

The kernel returns two file descriptors:

FD 3 -> pipe read end
FD 4 -> pipe write end
Enter fullscreen mode Exit fullscreen mode

The numbers 3 and 4 are only examples. The kernel returns available descriptor numbers in the calling process.

The pipe is a unidirectional byte stream maintained by the kernel. Bytes written through its write end can be read through its read end. It has no application-level idea of lines, JSON objects, or log records. Those meanings belong to the programs using it.

At this point, the shell might have a descriptor table like this:

Bash

FD 0 -> terminal input
FD 1 -> terminal output
FD 2 -> terminal error output
FD 3 -> pipe read end
FD 4 -> pipe write end
Enter fullscreen mode Exit fullscreen mode

The pipe exists, but neither command exists yet.

Each command gets a process

The traditional model is fork() followed by execve().

Bash forks a child for cat. It also forks a child for grep:

Bash PID 1000
   |
   +-- child PID 1101
   |
   `-- child PID 1102
Enter fullscreen mode Exit fullscreen mode

After fork(), a child inherits copies of its parent's open file descriptors. Those descriptors refer to the same underlying open-file descriptions as the parent's descriptors. The child therefore inherits access to both ends of the pipe.

The child still begins as a copy of the shell process. It has not become cat or grep yet.

That happens through execve().

Child PID 1101 --execve()--> cat PID 1101
Child PID 1102 --execve()--> grep PID 1102
Enter fullscreen mode Exit fullscreen mode

execve() replaces the program running inside a process. The process keeps its PID, while its code, stack, heap, and other program state are replaced for the new executable. File descriptors normally remain open across execve() unless they carry the close-on-exec flag.

This distinction is easy to miss:

fork()   creates a new process
execve() replaces the program inside a process
Enter fullscreen mode Exit fullscreen mode

Real shells have implementation details and optimizations. They may use posix_spawn() on some systems, and built-ins can follow different rules. The fork() plus execve() model still exposes the mechanics cleanly.

dup2() does the wiring

The children inherited the pipe, but their standard streams still point at the terminal. Bash must rearrange the descriptors before executing the programs.

The cat child needs its standard output connected to the write end of the pipe:

dup2(pipe_write, STDOUT_FILENO);
Enter fullscreen mode Exit fullscreen mode

Using the example descriptor numbers, that is:

dup2(4, 1);
Enter fullscreen mode Exit fullscreen mode

Afterward, descriptors 1 and 4 refer to the same pipe endpoint:

cat child

FD 1 --+
       +--> pipe write end
FD 4 --+
Enter fullscreen mode Exit fullscreen mode

The child can close descriptor 4. Standard output already points to the required endpoint.

The grep child needs the other side:

dup2(pipe_read, STDIN_FILENO);
Enter fullscreen mode Exit fullscreen mode

With the example numbers:

dup2(3, 0);
Enter fullscreen mode Exit fullscreen mode

Its standard input now refers to the pipe's read end:

grep child

FD 0 --+
       +--> pipe read end
FD 3 --+
Enter fullscreen mode Exit fullscreen mode

Once both children have been prepared and the unnecessary descriptors closed, their useful descriptor tables look like this:

cat
  FD 0 -> inherited input
  FD 1 -> pipe write end
  FD 2 -> terminal

grep
  FD 0 -> pipe read end
  FD 1 -> terminal
  FD 2 -> terminal
Enter fullscreen mode Exit fullscreen mode

Bash can now execute the programs. cat writes to FD 1 as usual, except FD 1 leads into the pipe. grep reads FD 0, which leads to the other end.

No special pipeline-aware version of either program is needed.

Closing descriptors is part of correctness

Every process inherits descriptors during fork(). That inheritance creates copies which must be closed when a process does not need them.

After starting both commands, Bash still holds its own copies of the pipe descriptors:

Bash
  FD 3 -> pipe read end
  FD 4 -> pipe write end
Enter fullscreen mode Exit fullscreen mode

Bash should close both. The children must also close every pipe end they do not use.

This affects observable behaviour.

A reader sees EOF only after every descriptor referring to the pipe's write end has been closed. Suppose cat finishes, but Bash accidentally keeps its copy of the write end open:

cat's write descriptor   -> closed
Bash's write descriptor  -> still open
Enter fullscreen mode Exit fullscreen mode

From the kernel's perspective, a writer still exists. grep can remain blocked, waiting for bytes that will never arrive.

The other direction has its own failure mode. Once every read end is closed, another write generates SIGPIPE. If the writer ignores that signal, write() fails with EPIPE instead.

You can see this relationship with:

yes | head -n 5
Enter fullscreen mode Exit fullscreen mode

head exits after five lines and closes its read side. yes keeps trying to write, so it encounters the broken pipe condition and stops.

Closing an unused descriptor is doing more than saving a small kernel resource. It changes when peers observe EOF and broken pipes.

One pipeline, several PIDs, one process group

The pipeline now moves data correctly. Bash still needs a way to treat its processes as one interactive job.

That is the role of a process group.

For example:

Bash
  PID  = 1000
  PGID = 1000

Pipeline
  cat   PID = 1101, PGID = 1101
  grep  PID = 1102, PGID = 1101
Enter fullscreen mode Exit fullscreen mode

The group does not require an additional process. One member is the process-group leader, and its PID is used as the PGID. In this example, cat is the leader because its PID and the group's PGID are both 1101.

An interactive shell normally arranges this with setpgid(). The exact ordering is more careful than a straight-line diagram suggests because the parent and children run concurrently. The shell has to avoid races while the children start and execute their programs.

The useful mental model remains simple: every external command usually receives its own PID, while all processes in one pipeline join the same process group.

Bash builds a job around that pipeline. A job is shell state: it has a job number, status, and command text. The kernel knows about processes and process groups. It does not know about Bash job specifications such as %1.

Shell job        user-facing bookkeeping in Bash
Process group    kernel mechanism beneath that job
Enter fullscreen mode Exit fullscreen mode

The terminal has a foreground process group

Process groups become especially useful once a terminal is involved.

A terminal tracks one foreground process group. tcsetpgrp() changes that group, while tcgetpgrp() reads it.

Before Bash runs the pipeline, the shell itself owns the terminal foreground:

Terminal foreground PGID = 1000
Bash PGID                = 1000
Enter fullscreen mode Exit fullscreen mode

Bash creates the pipeline and gives terminal foreground control to PGID 1101:

Terminal foreground PGID = 1101

PGID 1101
  |- cat
  `- grep
Enter fullscreen mode Exit fullscreen mode

Bash is still alive. It has simply moved outside the terminal's foreground group while it waits for the job.

This foreground relationship controls which job can normally read from the terminal. It also decides where keyboard-generated job-control signals go.

What Ctrl+C actually does

Pressing Ctrl+C does not ask Bash to choose a PID and kill it.

In a normal terminal session, the path looks more like this:

keyboard
   |
   v
terminal emulator
   |
   v
pseudoterminal master
   |
   v
pseudoterminal slave
   |
   v
N_TTY line discipline
   |
   v
SIGINT sent to the foreground process group
Enter fullscreen mode Exit fullscreen mode

The terminal emulator commonly writes byte 0x03 for Ctrl+C to the PTY master. On the slave side, Linux's default N_TTY line discipline processes the input.

Terminal settings define an interrupt character named VINTR. Its usual value is ^C. When the ISIG setting is enabled, the line discipline interprets that character and generates SIGINT. The byte is consumed as terminal control input instead of being delivered to the foreground program as ordinary stdin data.

The target is the terminal's foreground process group.

In the example, both processes receive SIGINT:

SIGINT -> PGID 1101
           |- cat  PID 1101
           `- grep PID 1102
Enter fullscreen mode Exit fullscreen mode

This explains why a whole pipeline usually reacts to one Ctrl+C. The pipe is not carrying the signal. Process-group membership gives the kernel the target set.

It also explains why Bash survives. Bash belongs to PGID 1000, while the terminal's foreground PGID is 1101.

SIGINT is a request the process can handle

The default action for SIGINT is process termination, which creates the familiar effect of Ctrl+C stopping a command.

A program can install a handler, block the signal temporarily, or ignore it. Python, for example, normally turns SIGINT into KeyboardInterrupt. Other programs use it to close files, restore terminal state, or ask the user to confirm an exit.

Every process in the foreground group receives the signal, but each one applies its own signal disposition. A pipeline can therefore react unevenly if one command catches SIGINT and another uses the default action.

SIGKILL has different rules. A process cannot catch, block, or ignore it. That is one reason kill -9 should not be the first way to stop a program: it removes any chance for application-level cleanup.

How Bash gets the prompt back

While the foreground job runs, Bash waits for its children. It needs to notice several possible outcomes:

  • a process exits normally;
  • a process terminates because of a signal;
  • a process stops, commonly after Ctrl+Z generates SIGTSTP.

Once the foreground job exits or stops, Bash makes its own process group the terminal foreground again. Then it updates the job's status and prints another prompt.

Pipeline running:
  terminal foreground PGID = 1101

Pipeline finishes or stops:
  terminal foreground PGID = 1000
  Bash prints the prompt
Enter fullscreen mode Exit fullscreen mode

fg and bg use the same model. fg continues a stopped job and gives its process group the terminal. bg continues the job without transferring foreground terminal ownership.

Watch the IDs yourself

Start a pipeline that stays alive long enough to inspect:

sleep 100 | sleep 100
Enter fullscreen mode Exit fullscreen mode

From another terminal, run:

ps -o pid,ppid,pgid,sid,tpgid,stat,comm -C sleep
Enter fullscreen mode Exit fullscreen mode

An example result might look like:

  PID  PPID  PGID   SID TPGID STAT COMMAND
 2101  2000  2101  2000  2101 S+   sleep
 2102  2000  2101  2000  2101 S+   sleep
Enter fullscreen mode Exit fullscreen mode

The exact values will differ. Look at their shape:

  • the PIDs are different;
  • the PGIDs match;
  • the TPGID matches the pipeline's PGID because this is the terminal's foreground job;
  • both processes belong to the same session.

Press Ctrl+C in the first terminal. Both sleep processes should disappear.

You can also inspect the terminal's configured special characters:

stty -a
Enter fullscreen mode Exit fullscreen mode

Look for values similar to:

intr = ^C
susp = ^Z
Enter fullscreen mode Exit fullscreen mode

These mappings are configurable. Ctrl+C is conventional rather than an unchangeable property of the keyboard.

A few boundaries around the model

The diagrams describe a normal interactive Bash session with job control enabled and external commands in the pipeline. They are a mental model, not Bash source code.

Shell built-ins complicate process placement. Bash usually runs pipeline components in subshell environments, while options such as lastpipe can change where the final component runs when job control is disabled.

Non-interactive shells commonly run without job control. They can still build pipes and execute multiple processes, but the foreground process-group handoff may not apply in the same way.

set -o pipefail changes how Bash calculates the pipeline's exit status. The processes stay separate.

And a longer pipeline simply extends the same wiring:

producer | transform | consumer
Enter fullscreen mode Exit fullscreen mode

It normally needs two kernel pipes:

producer stdout
      |
      v
   pipe A -> transform -> pipe B -> consumer
Enter fullscreen mode Exit fullscreen mode

Each process closes every pipe endpoint it does not need. The shell places the commands into a shared process group when interactive job control calls for it.

The whole path

The original command was short:

cat file.txt | grep error
Enter fullscreen mode Exit fullscreen mode

Its working shape is larger:

Bash parses the pipeline
        |
        v
creates a kernel pipe
        |
        v
creates a process for each command
        |
        v
uses dup2() to connect stdout and stdin
        |
        v
closes unused descriptors
        |
        v
executes cat and grep
        |
        v
places them in one process group
        |
        v
gives that group the terminal foreground
        |
        v
waits for the job
Enter fullscreen mode Exit fullscreen mode

Then Ctrl+C takes a different path through the same structure:

Ctrl+C
  -> PTY
  -> N_TTY line discipline
  -> SIGINT
  -> foreground process group
  -> cat and grep each decide how to respond
Enter fullscreen mode Exit fullscreen mode

The pipe moves the data. The process group gives the job a boundary. The terminal uses that boundary when you ask the foreground work to stop.

Sources

Top comments (0)