Proof of concept mechanical port of ircnet/ircd to Rust as part of a bit about C being insecure for network services
0

Configure Feed

Select the types of activity you want to include in your feed.

ircd.rs / docs / blog / 014-a-daemons-first-job-is-to-stop-being-watched.md
16 kB

title: "A daemon's first job is to stop being watched" desc: "The function right above last post's launcher is daemonize, and its entire purpose is to sever the process from the terminal that started it — close 0/1/2, fork, let the parent exit, drop the controlling tty. Running it faithfully kills the test harness. So here's how you build a differential test for a function you're not allowed to let do its job." date: 2026-06-06#

There's a comment in ircd.c, sitting one line above the call I want to talk about, that reads like a warning sign nailed to a door:

/* ircd/ircd.c:1151 */
/* daemonize() closes 0,1,2 -- make sure you don't have any fd open */
daemonize();

Whoever wrote that was being kind to the next person. daemonize is the function a Unix daemon calls to become a daemon — to cut itself loose from the terminal that launched it and go live in the background where nobody's looking. The comment is the original author telling you, plainly, that this function is about to blind the process. Close any descriptors you cared about first, because after this they're gone.

Last post I ported the launcher that forks and execs iauth. daemonize sits directly above it — it's the second-to-last thing s_bsd.c had left to port, and it's called exactly once, from main, right at boot. Porting it was easy. Testing it was the interesting part, because this is the first function I've hit whose whole job is to make itself impossible to watch.

The detach ritual#

Here's the function, config resolved down to what actually compiles on Linux:

/* ircd/s_bsd.c:794 */
void	daemonize(void)
{
	int fd;

	if (bootopt & BOOT_TTY)	/* debugging is going to a tty */
		goto init_dgram;

	(void)fclose(stdout);
	(void)close(1);

	if (!(bootopt & BOOT_DEBUG))
	{
		(void)fclose(stderr);
		(void)close(2);
	}

	if (((bootopt & BOOT_CONSOLE) || isatty(0)) &&
	    !(bootopt & BOOT_INETD))
	    {
		if (fork())
			exit(0);
		if ((fd = open("/dev/tty", O_RDWR)) >= 0)
		    {
			(void)ioctl(fd, TIOCNOTTY, (char *)NULL);
			(void)close(fd);
		    }
		(void)setpgrp(0, (int)getpid());
		(void)fclose(stdin);
		(void)close(0);
	    }
init_dgram:
	resfd = init_resolver(0x1f);
	start_iauth(0);
}

Walk the detach path top to bottom and it's the textbook daemonization dance. Close standard out. Close standard error too, unless you're debugging. Then, if you're attached to a console or a tty and weren't started by inetd, fork and have the parent exit(0) — that's the move that severs you from the shell that ran you, because the shell is waiting on the parent and the child gets reparented to init. Open /dev/tty and TIOCNOTTY it to drop the controlling terminal. setpgrp into your own process group so a stray signal to the old group can't reach you. Close standard in. And only now, detached and quiet, call init_resolver and start_iauth to bring up the two subsystems the daemon needs.

Mara (hacker): every line in that block is a different way of cutting one tie to the launching environment. fd 1, fd 2, the parent process, the controlling terminal, the process group, fd 0. By the bottom of it there is no terminal, no parent, and no standard streams. That's not a side effect of going to the background — that is going to the background.

You can't run the thing that erases the runner#

Now think about what it means to put that under a differential test.

A differential test calls the C original and the Rust port and compares what they do. To compare what daemonize does on its main path, I'd have to actually run the detach. Which means: close fd 1 and fd 2 — those are my test runner's stdout and stderr, the channels cargo uses to tell me what passed. Then fork and exit(0) the parent — and my test process is the parent. The function's success condition is that the process which called it ceases to exist. There's no assertion after exit(0), because there's no "after" in that process. The test harness is gone.

Cadey (coffee): I keep a little ranking in my head of how hostile a function is to being observed. start_iauth last post was bad — its success runs off into a child that execs into another program. This one's worse. Its success kills the parent. The function isn't indifferent to my test harness, it's actively dismantling it.

It's tempting to reach for fork-isolation here — run the dangerous thing in a child process, let it wreck itself, inspect the corpse from the parent. That's the trick I leaned on last post. It doesn't save me this time, and the reason is kind of the whole point: the thing daemonize produces is a detached process. A process with no controlling terminal, no parent waiting on it, no standard streams. Even if I fork a sacrificial child to run it in, what I'd be inspecting is a successfully-detached daemon, and "successfully detached" has nothing in it to diff. There's no return value, no error code, no struct it filled in. The evidence of success is the absence of all the things I'd use to observe it.

This is a different flavor of untestable than I've written about before. In post eleven the dial-out's success path was untestable because it needed infrastructure a unit test doesn't have — a live peer, an event loop. That's a can't afford it problem. This is a can't survive it problem. The function is fine to run. Running it just takes the observer down with it.

The one path that doesn't hide#

Look back at the very first line of the body:

	if (bootopt & BOOT_TTY)	/* debugging is going to a tty */
		goto init_dgram;

BOOT_TTY is the flag you get from booting with -t. The version banner calls it "foreground mode," and that's exactly what it is: don't detach, stay attached to this terminal, I'm watching you. When it's set, daemonize jumps straight over the entire detach block to the init_dgram: label and just runs the two tail calls. No fclose, no close, no fork, no exit. The process stays right where it is.

That's the path I can test. It's the only path that leaves a live, observable process behind to assert against. And here's the bit that turned a problem into a gift: it's also the only path anything ever takes in this project's test rigs. The golden harness boots every reference daemon with -t:

// ircd-golden/src/lib.rs:50
boot_args(binary, &["-t", "-s"])

So the detach path isn't just untestable — it's also a path that no test in the suite has ever run or ever will, because you don't background the daemon you're trying to script wire output from. The path I'm structurally forced to test is the same path every other test in the repo already runs. I'm not testing a convenient fiction; I'm testing the configuration the whole harness lives in.

Proving it fell all the way through#

My L1 differential pins BOOT_TTY and checks that both worlds skip the detach and land on the two tail calls correctly. The clever part — credit to whoever's been designing these oracle cases, which is me, but past-me deserves it — is that you can't just assert "nothing detached," because nothing happening is also what a broken short-circuit looks like. You have to prove the function ran all the way to the bottom. So the test reaches for the side effects of the two tail calls:

// ircd-testkit/tests/daemonize_diff.rs:67
#[test]
fn daemonize_boot_tty_init_dgram() {
    let _g = anchors();
    unsafe {
        // Rust world.
        bootopt = BOOT_TTY | BOOT_NOIAUTH;
        adfd = -1;
        resfd = -99; // sentinel: must change.
        c_daemonize();
        let rs_resfd = resfd;
        let rs_adfd = adfd;

        // Oracle world.
        cref_bootopt = BOOT_TTY | BOOT_NOIAUTH;
        cref_adfd = -1;
        cref_resfd = -99;
        cref_daemonize();
        // ...

        // init_resolver ran: resfd changed from the sentinel to a valid socket fd.
        assert_ne!(rs_resfd, -99, "Rust: init_resolver(0x1f) not reached");
        assert!(rs_resfd >= 0, "Rust: resfd not a valid socket fd: {rs_resfd}");
        assert_eq!(rs_resfd >= 0, cref_resfd_v >= 0, "resfd success disagrees");

        // Inverse check: start_iauth(0) reached but short-circuited on BOOT_NOIAUTH
        // → adfd untouched in both worlds.
        assert_eq!(rs_adfd, -1, "Rust: start_iauth touched adfd: {rs_adfd}");
        assert_eq!(cref_adfd_v, -1, "cref: start_iauth touched adfd");
    }
}

Two assertions, pulling in opposite directions. resfd starts at a -99 sentinel that's not a valid anything; if it comes back a real file descriptor, init_resolver ran and opened the resolver's socket — the function reached the bottom. And adfd, the iauth descriptor from last post, starts at -1 and has to stay -1, because I also set BOOT_NOIAUTH, which makes start_iauth(0) bail the instant it's called. So one tail call has to leave a mark and the other has to leave none, and both worlds have to agree on both. That's the whole signature of "the short-circuit worked, the function ran to completion, and it did exactly the two things it should at the end."

Cadey (coffee): the positive check proves the function got somewhere; the inverse check proves it got somewhere and then correctly chose to do nothing. Asserting on the no-op is the part people skip, and it's the part that catches a port that fell through to the bottom by accident instead of on purpose.

Both worlds use the usual setup from the oracle post: c_daemonize is the live binary's symbol, redirected through the partial-port seam to my Rust definition, and cref_daemonize is the byte-for-byte C original with its symbol renamed, kept alive in a static archive. Both run, both mutate their own copies of resfd/adfd, and they're serialized behind a mutex because all of this is process-global state and cargo runs tests in parallel threads — the shared-global hazard that's been shadowing every P7 test.

A two-argument call to a function that takes none#

One small faithfulness wrinkle worth flagging, because it rhymes with last post's vfork-but-keep-the-message beat. The C calls setpgrp(0, (int)getpid()) — two arguments. On Linux, glibc's setpgrp is the POSIX setpgrp(void); it takes no arguments and ignores anything you hand it. The two-arg form is a BSD-ism the source carries for the platforms where that branch was meant to compile.

I declared it two-arg in the Rust anyway:

// ircd-common/src/s_bsd.rs:2447
// glibc `setpgrp(void)`; the C source calls it `setpgrp(0, getpid())` and the
// extra args are ignored — declared 2-arg here to mirror the source verbatim.
fn setpgrp(pid: c_int, pgrp: c_int) -> c_int;

That's a smaller thing than keeping a wrong error string — nobody can observe the arguments to a function that throws them away, so this one's faithfulness for the reader rather than faithfulness on the wire. But the same instinct is underneath both: when the platform makes a piece of the source moot, mirror the source's shape anyway and leave a comment saying why, so the next person diffing this against the C sees the same call they'd see in the original. The TIOCNOTTY block earns its place the same way — it's defined on Linux, so the ioctl actually compiles and I ported it straight, rather than guessing it away.

What this test doesn't prove#

Here's the honest ledger, and it's a lopsided one this time.

The detach path has zero differential coverage. Not "a branch I didn't get to" — the entire reason the function exists. fclose/close of 1 and 2, the fork and the parent's exit(0), the /dev/tty open and TIOCNOTTY, the setpgrp, the final close of 0 — none of it is exercised by the oracle, and at L1 none of it can be, for all the reasons above. I ported it by reading it carefully against the C and matching it line for line. That's a port verified by inspection, not by the differential, and I'd rather say that out loud than let the green checkmark on daemonize_boot_tty_init_dgram imply more than it covers.

Could it be tested at all? Probably, but not cheaply and not here. You'd boot the real daemon without -t, then from the outside confirm the thing actually backgrounded itself — new session, no controlling terminal, parent reparented to init. That's an end-to-end behavior check living in L2 or a soak run, observing the process from the outside the way the OS does, not a unit test trying to observe it from the inside while it dismantles the inside. I wrote that down in the plan as the place the real coverage has to come from, the same way I logged the unreached iauth burst last post. A TODO with an address on it beats a passing test wearing a coverage costume.

Mara (hacker): the recurring shape across these last few posts: the closer a function gets to the operating system, the more its real behavior happens somewhere a unit test can't stand. Kernel-filled socket bytes, a child past execl, a process with no terminal. The oracle's reach stops at the syscall boundary, and these boot-time functions live right on it.

The shape of it#

So that's daemonize: a function I tested by pinning the one flag that tells it not to do the thing it's for. The detach itself — the part that's the entire point — I read by eye and matched to the C, and parked the real verification in L2 where you can watch a process from the outside. The part I could actually put under the oracle was the boot-init tail at the bottom, and that's the part I leaned on.

With it ported, s_bsd.c has exactly one thing left that's still C: read_message, the event loop, and its two little static helpers. The file I've been eating one function at a time since P7 is down to its spine. That's a different kind of post, and it's the next one I'm nervous about.

Honestly, the comment was right. daemonize() closes 0, 1, and 2. I just made sure the only time my test runs it, it's been told to keep them open.


The port is ircd-common/src/s_bsd.rs (daemonize, with the setpgrp and TIOCNOTTY config notes); the single-path differential is ircd-testkit/tests/daemonize_diff.rs; and the per-leaf plan that scoped it — including the "untested by design" note on the detach path — is 2026-06-06-p7ee-daemonize, under the phase plan in PLAN.md. The launcher post is the start_iauth this function calls at the bottom, post eleven is the other untestable-success-path function for contrast, and the oracle is the cref_ rename both worlds lean on.

And the standing reminder, true on every post here: this is an unsanctioned experiment on someone else's code. Leave the IRCNet team out of it. The function that backgrounds the daemon, the one flag I used to stop it from backgrounding my test, and the detach path I read instead of ran are mine.