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 / 006-the-retrospective-is-where-you-round-up.md
15 kB

title: "The retrospective is where you round up" desc: "Six phases into porting a thirty-year-old C daemon to Rust, I've hit the point where a project takes its victory lap. A victory lap is rounding up, and rounding up is the one thing this whole project was built not to do. So instead: the un-rounded ledger of what the differential harness actually caught — including the times it caught me, my own tests, and the reference C itself." date: 2026-06-06#

Six phases of this port are done, the .c files dropped from the build one translation unit at a time, and the seventh — the event loop, the part everything else has been leaning on — is open on my other monitor right now. That's a natural place to stop and write the post where you tally up the wins. The codebase is most of the way to Rust. The differential harness is green. By every normal measure this is the part where I get to feel good about it.

And a retrospective is exactly the place a project quietly rounds 96% up to 100%. You stop counting the things that are still C. You describe the plan as if it were the result. You let "mostly done" decay into "done" because nobody in the room is going to make you grep for the counterexample. I have read that post a hundred times on a hundred other projects and I have written it myself more than once.

So this isn't a victory lap. The whole pitch of this thing is that you don't get to claim more than the oracle can prove, and the moment you take a retrospective is the moment that rule is most inconvenient and most load- bearing. Here is the ledger I'd rather not round.

What's actually Rust#

Let me be concrete about the scorecard first, because "six phases" means nothing without the contents:

  • P1 — the leaf utilities: match(), the dbuf chains, the pure slice of support.c. Rust.
  • P2 — the data structures: the patricia tree, the hash tables, the client allocator with its self-referential pointers. Rust.
  • P3 — the formatting and the delivery core of the send buffer. Rust (mostly; hold that thought).
  • P4 — the tokenizer and the msgtab[] dispatch table. Rust, parse.o dropped outright.
  • P5 — the command handlers. All sixty-odd of them, channel.c and s_user.c and s_serv.c and the rest. Rust.
  • P6 — the config grammar and the entire bundled DNS resolver. Rust.
  • iauth — the authentication slave, thrown out and rewritten rather than ported. Rust.

What is not Rust: the I/O core and the auth socket plumbing I'm in the middle of now (P7), the ~25 variadic sender trampolines that can't be deleted until their call sites are (P8), and every idiomatic-Rust cleanup pass after that (P9 through P12, where this finally stops being a transliteration and starts being a program). So the honest sentence is: the logic is becoming Rust, the C is shrinking to a stub of variadic glue, and "100%" is still a scheduled event with other events in front of it. Not today's number.

That's the boring half of honesty — just not overstating the denominator. The interesting half is what the harness caught while I was getting here, because every one of those catches is an argument for why the bar had to be set at bytes and not at behavior.

It caught my tests before it caught the port#

Here's the one that humbled me first, and it wasn't even the port being wrong.

The L1 differential tests link the real C function — symbol-renamed to cref_… — right next to my Rust and assert the two produce identical output. One of them, checking the user-mode diff string, declared its scratch buffers as [0i8; 64] and handed .as_mut_ptr() to the extern function. Compiled fine on my x86-64 box. Then it hit CI on aarch64 and four lines lit up red with E0308, because c_char is signed on x86-64 and unsigned on ARM, and a hardcoded i8 only agrees with the FFI signature on exactly one of the two.

Mara (hacker): This is the kind of bug that's invisible until the day it isn't. char signedness is implementation-defined in C and the platforms genuinely disagree — x86-64 Linux picks signed, aarch64 Linux picks unsigned. Rust's std::os::raw::c_char is a type alias that resolves per target to match, which is the whole point of it existing. Hardcode i8 and you've quietly nailed your test to one architecture. The fix is a one-token change to [0 as c_char; 64] and a note to never type a C-char buffer as anything but c_char again.

Nothing about the actual port was wrong there. The test was wrong, in a way that only a second architecture could see, and the only reason I found out is that the oracle runs everywhere the reference build runs. A looser harness — one that checked "does the mode string look right" instead of "are these bytes identical on this exact target" — would have sailed straight past it. Precision caught my own sloppiness before it could catch anything else, which is a deeply annoying way to be taught that the harness works.

It caught the reference being wrong#

Then there's the failure I am not allowed to fix, and it's my favorite one.

One of the server-to-server golden tests diffs the STATS m output between the two daemons during a link burst, and it goes red on a clean checkout — no in-progress work, nothing of mine touched. The divergence is in the reference C. When a peer is mid-burst, report_myservers reads that peer's send-queue size before it's been initialized, and prints whatever garbage was in the struct:

…0kB recv 3544950785030750208kB sq BURST V1…

That 3544950785030750208kB is not a number anyone's IRC server is buffering. It's an uninitialized field being formatted as if it were real, and it's been in the C this whole time. My Rust port reads the same field at the same moment and — because of how its own allocation happened to land — prints 0kB. Both are "correct" in the sense that both faithfully render whatever happens to be in that memory. The reference just happens to render garbage there, deterministically enough to fail a byte-diff but not deterministically enough to fix by matching.

Cadey (coffee): I sat with this one for a while because it's the cleanest possible demonstration of what a differential harness is for. The oracle isn't a spec. It's a thirty-year-old running program, warts and uninitialized reads and all, and when your test is "be byte-identical to the oracle" you will eventually find a place where the oracle is byte-identical to a bug. The harness didn't fail. It succeeded at telling me the reference is nondeterministic here, which is information I would never have gone looking for on my own.

You can't reproduce a specific uninitialized value. That's what uninitialized means. So instead the field gets masked in the canonicalizer with a comment explaining why, so the next person who sees it red doesn't burn an afternoon hunting a regression that shipped in 1997. Faithful-to-the-bugs has a limit, and the limit is exactly the point where the bug stops being deterministic. Saying so out loud is part of the job.

It caught me being correct#

I told the long version of this last time, so here's the short one: in the iauth rewrite I wrote a clean little function that built an options string the obvious way, and the gate went red because the original C has a dead-else if chain that can only ever emit one of its three possible strings. My version was more correct. That made it wrong. I deleted my nice logic and reproduced the bug on purpose.

This keeps happening because the instinct to fix never goes away and the rule never bends, so they grind against each other in every phase. The reflex that makes you a decent engineer — see broken thing, fix broken thing — is the exact reflex a faithful port has to suppress. A "fix" is a behavior change, and a behavior change is a thing I can't prove safe. So mostly the job is learning to sit on my hands.

The floating point has to match too#

If you want the moment faithfulness stopped being a principle and started being a genuine pain, it's the auto-connect preference calculation in ircd.c. The server periodically scores each configured link by how well it's been responding, and the math runs through pow:

/* ircd/ircd.c — calculate_preference */
f  = (double)cp->recvd / (double)cp->seq;
f2 = pow(f, (double)20.0);
if (f2 < (double)0.001)
        f = (double)0.001;
else
        f = f2;
f2 = (double)cp->ping / (double)cp->recvd;
f  = f2 / f;
if (f > 100000.0)
        f = 100000.0;
aconf->pref = (u_int) (f * (double)100.0);   /* <-- truncation to integer */

Look at that last line. A double gets multiplied by 100 and then truncated to a u_int. Truncation is a cliff: f * 100.0 being 4217.9999999 versus 4218.0000001 is the difference between pref = 4217 and pref = 4218, and that integer goes out on the wire. So if the pow on the third line disagrees with C's pow by a single bit in the last place, the final integer can land on the wrong side of the cliff, and the golden diff goes red.

Mara (hacker): f64::powf in Rust's std and libc's pow are both correctly-rounded-ish IEEE 754 implementations, but "ish" is doing real work there — pow is notoriously hard to round correctly in the last bit, and different implementations make different last-ULP choices for the same inputs. They agree to about a part in 10^15. That's astronomically more than precise enough for literally any purpose except the one where you immediately truncate the result to an integer and put it on a wire that another program is going to byte-compare.

So the Rust port doesn't call f64::powf. It reaches through FFI and calls libc's pow, the exact same symbol the C calls, because the only way to guarantee the truncation lands on the same integer is to feed it the same last bit. That is an absurd thing to do in idiomatic Rust and it is the correct thing to do in a faithful port, and the gap between those two sentences is the entire reason this project takes phases 9 through 12 to un-do everything phases 1 through 8 so carefully preserved.

What was harder than the plan said#

Two things blew past their estimates, and both for the same underlying reason: shared mutable state doesn't respect your module boundaries.

My plan modeled P5 — the command handlers — as a list of files to port. In practice channel.c alone has 37 file-static functions all reading and writing the same private state: set_mode, can_join, get_channel, a few dozen friends. You can't lift one m_* handler out to Rust while leaving its neighbors in C, because they share statics the linker won't let you split. So the unit of porting turned out not to be a function, or even a file. It was a connected component over shared static state, and P5 fractured into 56 separate clusters to respect those components. None of that was in the plan, because I couldn't see it until I was inside the file.

Data had its own version of the same surprise. The numeric-reply table replies[] looked like a thing you could mechanically extract to a Rust array, until you notice it's full of #if/#else config branches, two bare NULL entries, and one string with a literal escaped \r\n in it (entry 384) that any source-level CRLF normalization would silently eat. So it doesn't get hand-translated at all — it gets dumped out of the compiled oracle as bytes, because the compiled oracle is the only thing that already resolved every branch correctly. Same story with alphabet_id[256] in s_id.c: its initializer stops a few slots short of 256, so the last handful of entries are whatever C's zero-fill makes them, and "reproduce that faithfully" means reproducing a reliance on default-initialization that no one would ever write on purpose.

Cadey (coffee): Every one of these is a small lesson in the same big thing — the C isn't a clean specification you're re-implementing, it's a running artifact with thirty years of accreted incidental behavior, and the incidental behavior is load-bearing whether anyone intended it or not. You don't get to decide which parts were "really" the design. The wire decides, and the wire remembers everything.

What the oracle actually bought#

Here's the part I'll let myself feel good about, narrowly. Six phases of this and I have never once had to wonder whether a port was faithful. Not hope. Not spot-check. Every dropped .c left behind a test that links the original beside the replacement and screams if a single byte moves, and that test has caught my architecture-specific sloppiness, the reference's own uninitialized garbage, my instinct to improve things, and floating-point rounding a part in a quadrillion off. The confidence is earned in a way I have basically never gotten on a rewrite before, and it's earned precisely because I didn't get to set the bar myself — the oracle sets it, at bytes, and bytes don't negotiate.

What it cost is real too, and I'd be rounding up if I skipped it. I'm carrying a floating-point FFI call I'll have to rip out later. I've reproduced bugs I'll have to un-reproduce in the idiomatic passes. The whole back half of the plan, P9 onward, exists only to undo the faithfulness the front half so carefully built — because "byte-identical to thirty-year-old C" and "good Rust" are not the same target, and you cannot hit the second one until you've first proven you hit the first. That's a lot of motion to end up where a clean-room rewrite could have started. I think the trade is worth it. I'm not certain, and I'd rather say that than pretend the certainty.

Five and a half phases to go, and the hardest one — the single-threaded event loop that every other piece has been quietly depending on — is the one open right now. That's the post I actually can't write yet, because it isn't true yet. Which is, I suppose, the entire point.


The phase plan, the keystone-hazard table, and the per-phase exit gates all live in PLAN.md; the differential harness those gates run against is the subject of post three. The earlier posts on why faithful at all, the variadic trampolines, and the one part I rewrote instead are the context this one is counting up.

And the standing reminder, true on every post in this series: this is an unsanctioned experiment on someone else's code. Leave the IRCNet team out of it — the ledger, bugs reproduced and all, is mine.