title: "The trampoline with no riders" desc: "Post four called the ~25 variadic sender trampolines the last C standing and promised they'd die in P8 by deletion, not translation. P8 started. The first one out had zero C callers left, and pulling it was almost an anticlimax — which is exactly why I picked it." date: 2026-06-07#
Back in post four I spent two thousand words on a
single asymmetry: stable Rust can call a C variadic but it cannot define one,
which is why a family of about twenty-five sendto_* trampolines was going to
outlive nearly every other line of C in this port. I
ended that post insisting that "100% Rust" was a goal with a date on it — P8 — and
that the trampolines would die there by deletion, not translation. Every call
site that says sendto_one(to, ":%s NOTICE %s :%s", ...) gets rewritten to build
the string with format! and hand it to a plain non-variadic Rust sender, and
once nobody needs the ... the variadic has no reason to exist.
P8 started. This is the first one out, and the part worth telling is how little of a fight it put up. The reason it was easy is the whole point.
Pick the one nobody's calling#
The first trampoline I deleted is sendto_iauth (ircd/s_auth.c:120-170), the
function that ships a line to the iauth slave process over its pipe. I did not pick
it because it was important. I picked it because of one line of nm output: there
are zero undefined references to sendto_iauth anywhere in the link set.
Unpacking that: by the time P8 starts, almost everything is already Rust —
the whole point of the strangler-fig order was to port
inward until the C is a hollow shell. Every caller of sendto_iauth had already
become Rust over the preceding phases. The only C that still mentioned the function
at all was inside #ifdef'd-out bodies — code that doesn't compile into the link
object. So at the moment I came to delete it, the function had no riders.
Twenty-four call sites, all of them Rust, all of them code I already control.
Cadey (coffee): I built this thing up in post four as a keystone horror, the most C-shaped C in the building, and then the first one came out like a loose tooth. No drama. I'd already done the hard part in P1 through P7 without noticing it was the hard part — I'd moved every caller across the seam one at a time, and deleting the function afterward was almost bookkeeping.
And this was deliberate. P8 has real monsters in it, and I wanted my first cut to be the one where I couldn't break a C caller I'd forgotten about, because there were none. You learn the mechanics on the safe one.
You don't translate the variadic, you move the va_start#
Here's the trick, and it's the same trick post four described from the other side.
The reason you can't port a variadic is the va_start/va_arg machinery in its own
signature. So you don't port it. You move that work to the callers, where Rust's
format! already does the same job, and the function it leaves behind isn't
variadic anymore.
The C sendto_iauth was a thin variadic shell around a worker that did a vsprintf
and a write loop:
/* ircd/s_auth.c:120 */
int vsendto_iauth(char *pattern, va_list va)
{
static char abuf[BUFSIZ], *p;
int i, len;
if (adfd < 0)
return -1;
vsprintf(abuf, pattern, va); /* the va_list dies here */
strcat(abuf, "\n");
p = abuf;
len = strlen(p);
do {
i = write(adfd, abuf, len);
/* ... EAGAIN/EWOULDBLOCK and lost-slave handling ... */
p += i;
len -= i;
} while (len > 0);
return 0;
}
int sendto_iauth(char *pattern, ...) /* the variadic wrapper */
{
va_list va;
va_start(va, pattern);
int i = vsendto_iauth(pattern, va);
va_end(va);
return i;
}
Its Rust replacement just takes the finished bytes. No pattern, no ..., no
va_list anywhere in sight:
// ircd-common/src/s_auth.rs:38
pub unsafe fn sendto_iauth(line: &[u8]) -> c_int {
if adfd < 0 {
return -1;
}
let mut abuf: Vec<u8> = Vec::with_capacity(line.len() + 1);
abuf.extend_from_slice(line);
abuf.push(b'\n');
let mut len = abuf.len() as isize;
loop {
let mut i = libc::write(adfd, abuf.as_ptr() as *const c_void, len as usize) as isize;
if i == -1 {
let e = *libc::__errno_location();
if e != libc::EAGAIN && e != libc::EWOULDBLOCK {
sendto_flag(SCH_AUTH, c"Aiiie! lost slave authentication process".as_ptr() as *mut c_char);
libc::close(adfd);
adfd = -1;
ircd_sys::bindings::start_iauth(0);
return -1;
}
i = 0;
}
len -= i;
if len <= 0 {
break;
}
}
0
}
And the formatting that used to live inside the C vsprintf moves out to the call
sites, which were already Rust. Here's a real before/after from register_user:
// before — calling the C variadic
sendto_iauth(c"%d U %s".as_ptr() as *mut c_char, (*sptr).fd, (*user).username.as_ptr());
// after — format! at the call site, hand over the bytes
sendto_iauth(format!("{} U {}", (*sptr).fd, ia_str((*user).username.as_ptr())).as_bytes());
That ia_str is the one new helper the conversion needed. The %s arguments at
these call sites are raw C char * pointers into client structs, and format!
wants something it can print, so ia_str reads a NUL-terminated C string into an
owned String (ircd-common/src/s_auth.rs:77). It's a lossy UTF-8 decode, which
sounds scary until you remember the iauth pipe protocol is plain ASCII — the lossy
read is byte-faithful to the C %s copy for every byte that can actually appear on
that wire.
Faithful to a bug nobody can hit#
One wart in that C loop is there on purpose, and it's a nice small example of what "faithful port" actually commits you to.
Look at the write loop again. It sets p = abuf, then in the loop it calls
write(adfd, abuf, len) — writing from abuf, the buffer base — but advances
p += i, never abuf. The p cursor is dead. If a partial write ever happened,
the next iteration would re-send the whole line from the start, duplicating the
bytes it already wrote. It's a real bug.
It just can't fire. The lines sendto_iauth sends are tiny, the pipe to a local
slave process accepts each one in a single write, and so the loop runs exactly
once and the broken cursor never gets used. Thirty years of this code running in
production agree.
Mara (hacker): The faithful-port question is always "is this observable?" A partial write on this fd is unreachable on the platforms anyone runs, so the bug has no behavior to be faithful to — but the structure of the loop is right there in the source, and re-deriving a "correct" loop would be me editing the program while claiming to translate it. So the Rust re-issues from the buffer base every iteration too. Same dead cursor, same harmlessness. If I'm ever wrong about it being unreachable, both worlds are wrong identically, and that's the contract.
Two things I left deliberately not differentially tested, and wrote them down
rather than let a green checkmark imply more than it should. One is a debug line
that formats raw heap-pointer addresses (-1 E last=%u start=%x …) — those differ
between the two processes by construction, so there's nothing to diff. The other is
the lost-slave hard-error branch, because I can't force a fatal write error
deterministically from a test. Both are ported by reading the C against the Rust,
which is weaker than a byte-diff, and saying so is the price of being able to trust
the parts that are byte-diffed.
The oracle is allowed to be the thing I can't write#
Here's the part I find genuinely funny. The whole reason this function had to be a
trampoline is that I can't define an extern "C" variadic in stable Rust. But the
differential oracle — the cref_ renamed copy of the
original C — is still C. It compiles from the unguarded s_auth.o. So cref_sendto_iauth
is allowed to be exactly the variadic I'm not allowed to write.
Which makes the test a clean A/B. I build the same iauth line two ways — through the
C oracle, still variadic, still formatting via vsprintf; and through the Rust core,
fed pre-formatted bytes — each writing to its own socketpair, and I diff the bytes
that land on the pipe:
// ircd-testkit/tests/sendto_iauth_diff.rs
// `<fd> U <user>` — the ident username push.
diff(b"42 U someuser", || {
cref_sendto_iauth(c"%d U %s".as_ptr(), 42 as c_int, c"someuser".as_ptr() as *const c_char)
});
One side runs the C I deleted from the binary, the other runs the Rust that replaced
it, and the harness checks the pipe bytes match. Every %s/%d format family that
actually appears at the twenty-four call sites gets a case, plus the
already-finished-buffer passthrough (where start_auth builds the whole C line
with sprintf and hands it straight in), plus the adfd < 0 inverse where both
worlds return -1 and write nothing. So the function is gone from the daemon but
still pinned by a test that rebuilds it on demand whenever I want to check.
One down, and it was the cheap one#
So sendto_iauth is Rust now, with no variadic anywhere in its lineage. The C
vsendto_iauth/sendto_iauth pair is #ifdef'd out of the link object behind
PORT_S_AUTH_SENDTO_IAUTH_P8a (ircd-sys/build.rs:292), nm confirms the symbol
is gone from the binary, and the only C left in s_auth.c is data globals. The
oracle copy lives on in libcref.a for the test.
I'm not going to round this up, though, because rounding up is the one thing this
whole series is supposed to not
do. That's one trampoline. There are roughly twenty-four left, and the two that
matter — sendto_one with something like 478 call sites, sendto_flag with around
255 — are the keystones, and they're hard for exactly the reason post four laid out.
sendto_iauth writes one line to one pipe; there's no per-recipient anything. The
iterating senders re-walk their va_list per target inside vsendpreprep, making
a different formatting decision for each one, so there's no "format once at the call
site" to hoist out the way there was here. The trick that made this one a loose
tooth doesn't straightforwardly apply to them.
Cadey (aha): the honest read is that I picked the trampoline whose deletion taught me the least about the ones I'm scared of. That's fine. It taught me the mechanics — the seam-inversion, the
ia_strbridge, the format-at-the-callsite rewrite, the oracle-stays-variadic test shape — on a function where a mistake couldn't take down a caller I'd forgotten. The keystones get those mechanics plus the part I haven't solved yet, which is whatformat!even means when the format depends on who's receiving it.
One other thing I'm holding off on. These call sites format with format! and raw
{} placeholders, not with leveva's typed Message and
identifier newtypes, even though leveva exists now. That's on purpose: sendto_iauth
carries the iauth pipe protocol — <id> <category> <info> — not IRC messages, so
the typed-message layer doesn't fit it. The typed senders show up alongside the IRC
clusters, where the content of the line genuinely is an IRC message and there's
something for the types to describe. Faithful bytes first. The nice types can wait
for where they'd actually do something.
The first trampoline came out without a fight because I spent seven phases quietly moving everyone off it first. The hard ones are still up there, full of riders.
You'll find the port and its ia_str helper in
ircd-common/src/s_auth.rs; the byte-diff is
ircd-testkit/tests/sendto_iauth_diff.rs;
and the per-trampoline plan, including why this was the cleanest first cut, is
2026-06-07-p8a-sendto_iauth,
under the phase plan in PLAN.md. Post
four is why these functions had to be C in the first
place, post three is the cref_ rename the test leans on, and
post five is the iauth process on the other end
of this pipe.
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 trampolines, the one I pulled and the keystones I'm still nervous about, are mine.