QUESTION
Why does a reader fail to receive EOF after the pipe writer finishes?
Pipe EOF occurs not when one writer process exits, but when every file reference to the write end is closed. If the parent waits while retaining an unused write end, the reader waits for EOF while the parent waits for the reader to exit, creating a deadlock.
If the target fd number is open, dup2/dup3 closes it and attaches the source open file description to that slot. After redirection, the original pipe fd must also close so the reference count decreases correctly.
STRUCTURE
Structure diagram
Writer process
- fd 1 → pipe write
- close original pipefd[1]
- read end close
Pipe object
- ring buffer
- readers=1
- writers=1
Reader process
- fd 0 → pipe read
- close original pipefd[0]
- write end close
Parent
- read end close
- write end close
- wait for both children
EOF occurs only after every fd owning a write end closes. Even one write end left in the parent prevents the reader from receiving EOF.
CALL PATH
Call path
Drawing fd numbers held by each process as rows and the shared pipe object as a column makes the cause of missing EOF easy to find. Every duplicate that a process does not use must be closed.
This diagram is not for memorizing function names. Follow which return value, file descriptor, memory mapping, or wait queue is passed to the next stage.
SOURCE COORDINATES
Linux 6.18.37 LTS source locations
Go beyond the glibc function to the files where the syscall implementation meets kernel objects. Each link points to the original file at the same tag.
| File | Function / structure | What to inspect |
|---|---|---|
| fs/pipe.c | do_pipe2(), pipe_read(), pipe_write() | pipe object, buffer, reader/writer count |
| fs/file.c | do_dup2(), replace_fd() | Atomically replace the target fd slot |
| kernel/fork.c | copy_files() | Share or duplicate the fd table at fork |
COMPLETE PROGRAM
Complete runnable example
The code below is not pseudocode with explanatory lines omitted. It is a minimal example that can be built and run as a file.
cc -std=c17 -Wall -Wextra -O2 pipeline.c -o pipeline01#define _GNU_SOURCE
02#include <fcntl.h>
03#include <stdio.h>
04#include <stdlib.h>
05#include <sys/wait.h>
06#include <unistd.h>
07
08int main(void)
09{
10 int pipefd[2];
11 if (pipe2(pipefd, O_CLOEXEC) < 0)
12 return 1;
13
14 pid_t writer = fork();
15 if (writer == 0) {
16 if (dup2(pipefd[1], STDOUT_FILENO) < 0)
17 _exit(127);
18 close(pipefd[0]);
19 close(pipefd[1]);
20 execlp("printf", "printf", "alpha\nbeta\n", NULL);
21 _exit(127);
22 }
23
24 pid_t reader = fork();
25 if (reader == 0) {
26 if (dup2(pipefd[0], STDIN_FILENO) < 0)
27 _exit(127);
28 close(pipefd[0]);
29 close(pipefd[1]);
30 execlp("wc", "wc", "-l", NULL);
31 _exit(127);
32 }
33
34 close(pipefd[0]);
35 close(pipefd[1]);
36 int status;
37 waitpid(writer, &status, 0);
38 waitpid(reader, &status, 0);
39 return 0;
40}
CODE NOTES
Code notes
pipe2(pipefd, O_CLOEXEC)Sets close-on-exec when the 2 fds are created. For the copies duplicated onto standard fds, inspect the attributes of the existing STDIN/STDOUT slots separately.
dup2(pipefd[1]Makes the writer's fd 1 refer to the same open file description as the pipe write end. If fd 1 was already open, replacement is atomic.
close(pipefd[1]);The original number is no longer needed after dup2. Leaving it open can preserve a reference in the exec target or delay EOF.
execlp("wc"Replaces the reader image with wc, while the fd 0 connection remains in a standard slot without CLOEXEC and becomes the pipeline input.
close(pipefd[0]);The parent must close both ends too. Closing them only in the children does not reduce the pipe object's reader/writer counts to 0.
DETAILS
Detailed behavior
A pipe is a byte stream
It does not preserve message boundaries. Small writes from multiple writers are guaranteed not to interleave up to PIPE_BUF, but the reader is not guaranteed to read in the same sizes.
The protocol must define its own delimiter or length prefix.
After dup, the open description is shared
Although the fd numbers differ, file status flags and offsets are shared through the same struct file used by source and target. FD_CLOEXEC is per fd slot, so the dup2 result does not simply copy the source FD_CLOEXEC.
New code can express intent with dup3 and O_CLOEXEC, with the difference that equal oldfd and newfd produce EINVAL.
The shell defines pipeline exit-status policy
Writer and reader terminate independently. The parent must decide whether to return only the last command's status or fail when any command fails, as pipefail does.
Whether a writer terminated by SIGPIPE is a service failure or a normal downstream termination likewise depends on pipeline semantics.
OBJECTS
Objects and lifetimes
| Object | Creation and release | Values to inspect |
|---|---|---|
pipe_inode_info | Created by pipe2 and released when all read/write files close | readers, writers, ring head/tail |
fd copy | Incremented by fork/dup and decremented by each close/exec | Which process owns which end |
pipe buffer | Filled by write and drained by read; sleeps or returns EAGAIN according to capacity | bytes, slots, wakeup |
FAILURE PATH
Failure conditions and common misconceptions
| Observed symptom | Likely causes | How to verify |
|---|---|---|
| The reader does not receive EOF | A write-end reference remains somewhere | Inspect /proc/*/fd and lsof |
| The writer terminates with SIGPIPE | Every read end is closed | Check wait status and signal |
| The pipeline occasionally hangs | A fork failure, missing close, or asymmetric error path | Compare fd lifetimes with strace -ff |
LAB
Verify it yourself
- Comment out the parent's close(pipefd[1]), confirm that wc fails to receive EOF, then restore it.
- Change the writer to emit a large data set and delay the reader to observe pipe-capacity backpressure.
- Print /proc/PID/fd links at each fork point and make a table of fd copies that refer to the same pipe inode.
./pipelinestrace -f -e trace=pipe2,clone,dup2,close,read,write,wait4 ./pipelinePRIMARY REFERENCES