D282 - One body for two arities read an argument it was never passed

decided · 2026-08-26 · the probe crashed the emulator, not the guest

The vendor and POSIX spellings of the same primitive differ in arity, not only in name. scePthreadRwlockInit takes three arguments - lock, attributes, name - and posix_pthread_rwlock_init takes two. Both were bound to one implementation, and that implementation read args[2] unconditionally.

For the POSIX call, args[2] is whatever the guest happened to leave in rdx. Here it was 0x3f, and read_name dereferenced it: read of 0x3f at a host instruction pointer. The emulator died, in its own code, on a guest call that was perfectly well formed.

Three things this is worth recording for.

A crash inside the emulator reads as a guest fault. The report said "the guest faulted at 0x7ff63fa1da58", and the address is ours. Every register in it is ours. Nothing in the wording distinguishes "the guest did something we do not handle" from "we dereferenced rubbish", and those want completely different work.

The arity is declared, and the implementation cannot see it. guest_module! records => 2 and => 3 a few lines apart; the function takes &[u64; GUEST_ARG_REGISTERS] and has no idea which of the two called it. The declaration knows the answer and the code that needs it is not given it - so the separate entry points are the fix, not a runtime check.

read_name trusts a guest pointer with nothing checking it, which is the larger hole and is not specific to this call. It scans up to sixty-four bytes from an address the guest chose, one byte at a time, with a comment observing that this bounds the overshoot rather than preventing it - and a one-byte overshoot into an unmapped page faults exactly as hard as a whole one. Any guest-supplied string pointer can end the run, in the emulator, from any implementation that names something. Principle 4 already says where that belongs: guest memory access is orbistoun-mem's, behind a checked accessor, and this is a raw std::ptr::read in a subsystem crate.

The immediate fix is separate entry points, because it is unambiguous and removes the crash. The checked accessor is the one that matters and is recorded as a thread rather than done in the same breath, because it touches every caller that reads a guest string.

A static guard for this was attempted and does not exist. The obvious one - two names sharing a body must agree on their declared arity - was written, and it failed immediately on snprintf_s (6) and snprintf (3), which share a body correctly: both pass destination, size and format in the first three registers. ImportDesc::arity is documented as a hint for how many registers a trace should record, "wrong arity degrades trace quality; it does not break the call", and it is deliberately inflated for variadics. So disagreement is legitimate and the guard was checking a different field from the one it claimed - the precise failure principle 3 names. It was removed rather than kept with an exception list.

What actually caught this was the conformance probe, which calls the POSIX spellings deliberately and by name. That is an argument for running it in the gate: a unit test cannot easily assert "this body does not read past its arity" without a process boundary per call, and the probe demonstrates it by doing it.