•
I built zmx as a multi-faceted learning exercise. I wanted to learn more about zig, posix, daemons, ptys, libghostty, and terminals. Creating a program that fits into my core dev toolchain was merely a side-effect. While researching about how to create a daemon, buried in the implementation of abduco, I discovered the double-fork.
•
zmx.sh
•
abduco.c
•
Hidden behind the double-fork technique is a cascade of terms and concepts that are fundamental to how linux manages processes. This post is me coming to the surface and intended to be a reminder when I read zmx and say "what the double-fork?"
what is a daemon?
•
A daemon belongs to a session that has no controlling terminal. For example, when backgrounding a process using ctrl-z+bg or {cmd} &, the session still has a controlling terminal which means it can control those background processes. This is not ideal for a daemon.
•
There are other ways to background a process that does disconnect it from the terminal, with varying degress of pros and cons:
•
nohup {cmd} &
•
setsid {cmd} &
•
{cmd} & disown
how does fork work in a program?
•
When using fork(2) in a program, it will create a child process in addition to still running the parent process that called fork. This was hard for me to grok when building out zmx. It is as-if the program clones itself at that exact line and both copies continue independently from there. Typically when creating a daemon you would use the child process of the fork and then kill the parent process.
•
fork()
│
┌────────────┴────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ PARENT │ │ CHILD │
│ PID = 42 │ │ PID = 55 │
│ PPID = 10 │ │ PPID = 42 │
│ │ │ │
│ (continues │ │ (continues │
│ from the │ │ from the │
│ fork point) │ │ fork point) │
└─────────────────┘ └─────────────────┘
•
Identical memory at fork point (copy-on-write)
•
Identical open file descriptors (shared offsets)
•
Different return value from fork():
•
parent → child's PID (55)
•
child → 0
•
Both run independently from here
what is a session?
•
In linux, a session is a container for process groups.
•
Process groups are a container for processes that receives signals from the same terminal.
•
At-most one process group in a session can be in the foreground.
•
It receives signals and can write to the terminal.
•
All other process groups in the session are background, and SIGTTOU/SIGTTIN will stop them if they write to or read from the terminal.
•
session/
├── process group (foreground)
│ ├── process
│ ├── process
│ └── process
└── process group (background)
├── process
└── process
what is a controlling terminal?
•
Only a session leader can create a controlling terminal for the session.
•
A session can have at-most one controlling terminal.
•
The session leader that opens the terminal becomes the controlling process.
why double-fork?
•
When launching a daemon, you normally set the child process of the fork to be the session leader via setsid() which creates a new session that removes the current controlling terminal but creates the authority to grab a new one. This is important because we don't want a controlling terminal for our daemon or else our daemon could receive signals to shutdown when the controlling terminal closes.
•
However, if the first fork's child process is also the daemon process, then it's technically possible for the daemon to open a terminal device (e.g. open("/dev/console", O_RDWR)) and then it would acquire a controlling terminal! A controlling terminal would expose the daemon to terminal-generated signals (e.g. SIGINT) or SIGHUP from terminal disconnect which could kill the daemon.
•
The first fork isn't arbitrary either: setsid() fails with EPERM if the caller is already a process group leader, which a process launched directly from a shell typically is. The first fork produces a child guaranteed not to be a group leader, so setsid() will succeed. By forking a second time, the grandchild process (the daemon) is not the session leader. Per POSIX, only a process that is the session leader can acquire a controlling terminal.
•
Apparently this is "a bit paranoid," and on Linux it is arguable since a session leader only acquires a controlling terminal under implementation-defined conditions. But the double-fork is the portable way to guarantee the daemon can never acquire one, regardless of how a given POSIX implementation behaves. So we baked it into zmx.
•
PID=42 SID=10 PGID=10 ← original (PG leader, has tty)
│
│ fork #1
├──────────┐
│ exit │ PID=55 SID=10 PGID=10 (not a PG leader)
✝ │
│ setsid()
▼
PID=55 SID=55 PGID=55 (session leader, no tty)
│
│ fork #2
├──────────┐
│ exit │ PID=73 SID=55 PGID=55
✝ │ PID≠SID → can't get a tty
▼
DAEMON ✓
beyond the double-fork
•
The double-fork detaches the daemon from any controlling terminal, but that's only one piece of a robust daemon. A daemon also needs to clean up everything it inherited from the parent that launched it like file descriptors, working directory, and standard streams.
•
In zmx, after the second fork, the daemon does two more things:
•
First, it redirects stdin, stdout, and stderr to /dev/null via dup2(2). This is critical: if the parent was launched by a test harness like bats, those file descriptors might be pipes. As long as the daemon holds the write end open, the harness will hang waiting for EOF. Redirecting to /dev/null closes that loop.
•
Second, it closes all inherited file descriptors from 3 up to 64 (skipping any that the caller explicitly wants to keep, like a server socket). This releases resources the daemon doesn't need and prevents the same hang on higher-numbered FDs.
•
We do *not* chdir because opening a shell in the cwd is a feature of zmx
•
We do *not* umask mainly because it has never been an issue
•
[1] fork() ── child is not a PG leader
[2] setsid() ── new session, drop tty, become leader
[3] fork() ── daemon is not the leader, can't get tty
─────────── double-fork ends, cleanup begins ───────────
[4] chdir("/") ── don't pin a mount point
[5] umask(0) ── predictable file-creation modes
[6] dup2 /dev/null ── stdin/stdout/stderr → /dev/null
[7] close fds 3-64 ── release inherited resources
refs
•
fork(2)
last updated: