what the double-fork?

· erock's devlog

creating a robust posix daemon for zmx

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 you background 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. This is important because we don't want a controlling terminal for our daemon or else it 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 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

preferred ways to daemon a process

Ideally you would be able to create a daemon using your OS supervisor like systemd, openrc, runit, etc. This was not a great option for zmx because I wanted it to be portable on linux and mac. I also wanted zmx to "just work" by auto-daemonizing the session during attach. So in the case of zmx, when we fork, the child process becomes the daemon and the parent process becomes the client that connects to the daemon via a unix socket.

refs

zmx/daemonize.zig
zmx/daemonize.zig (stdio + fd cleanup)
fork(2)
1.7 How do I get my program to act like a daemon?
Why fork twice? (StackOverflow)
The Controlling Terminal
last updated:

We're all in the gutter, but some of us are looking at the stars.
-- Oscar Wilde

Subscribe to my rss feed to read more posts.