P4 step 3 — the staging buffer stops being a requirement

execve read every program into a 4 MiB staging buffer, and the interpreter into another 2 MiB one, so that a parser could look at the first hundred bytes and a handful of pages could be copied out. Six megabytes of kernel memory, and a whole-file read on every exec, to serve two things that need a few kilobytes between them.

This step removes the need for it. Both consumers now ask for bytes instead of indexing a buffer.

The page fill

vibeos_elf_fill_page_via takes a reader. A page of virtual address maps to at most one contiguous file range per segment, so it issues at most one read per segment that covers the page — not one per byte, and not one for the whole file.

The kernel's reader serves those bytes from the page cache, which is where they already are: X-P2 maps most image pages straight out of it, and the ones that have to be copied were the only reason the file was in the staging buffer at all. A range can straddle two cache pages, and the loop that handles it is not decoration — a segment's file offset has no reason to be page-aligned, and a version assuming one lookup per read would corrupt every page whose data crossed a boundary, which is most of them and only for some programs.

A read that fails leaves the rest of the page zeroed rather than half-written. A partially filled page of program text is indistinguishable from a correct one until it is executed.

The pointer form is kept as a reader over memory, so the host tests and the bootloader — which have the whole file to hand — do not have to invent a callback. Same code path, deliberately: two fills that could drift apart is how the copied pages and the mapped pages come to disagree about a program.

The parse

Everything the parser needs is in the first few kilobytes — the ELF header and the program headers — with exactly one exception: the interpreter path, which is the contents of a PT_INTERP segment at an arbitrary file offset. That single access is the entire reason image had to be the whole file.

vibeos_elf_parse_read takes a reader for it. Pass null and it behaves exactly as before, taking the path from the buffer and refusing a range that reaches past it.

One thing moved while doing this: the check that the path is terminated inside its own segment now happens after the copy rather than before it. With a reader the bytes are not addressable until they have been fetched, and checking a different copy from the one that gets used is how the two come to disagree.

What is not done, and what it is waiting for

The buffers are still there, and still 6 MiB. Nothing needs them now, but shrinking them is blocked on something outside this phase.

hw_read_file_cached returns one number that means two things: how long the file is, and how many bytes it put in the buffer. Today those are the same because the buffer holds the whole file. Shrink the buffer and they diverge — and the parse needs the file length for its bounds checks while the buffer only holds the headers. Passing the staged count would make the parser refuse every segment past the window, which is every segment.

Separating them is a change to the I/O path, and that path's truncation semantics are the subject of one of this project's most expensive lessons: a FAT read that reported a short read as a complete file, so execve parsed whatever the previous program had left in the shared buffer. g_fat_chain_error and a byte count that reflects what was actually copied exist because of it.

So this belongs to the disk-and-I/O refactor rather than to the memory manager, and doing it here would mean changing that function's contract in passing — which is how that defect happened the first time.

What this phase leaves behind is that the dependency is gone: the staging buffer is an optimisation now, not a requirement. When the I/O layer can say "the file is N bytes, I gave you M of them", both buffers can shrink to a header window and the refusal for a file whose headers exceed it is one line.


Update, after I3 of docs/io/

The I/O layer can say it now: the read reports the file's length and stages only what fits, vibeos_elf_header_extent says how much of a file the parser needs, and hw_proc_create takes both numbers with the signature saying which is which. The refusal for a file whose headers exceed the window is one line, as predicted.

The buffers still do not shrink, and the reason changed. It is no longer a design problem; it is a defect with a reproduction.

Setting both windows to 64 KiB works and runs every program on the media, and turns the gate red with mm_poison_hits=3019 deterministically. Shrinking either window alone is clean; only both together fail; and wasting the six megabytes the two shrinks free makes the poison disappear while the windows stay small. So the shrink changes only which frames the allocator hands out, and something writes to a frame it has already released.

That is the use-after-free this subsystem has been chasing at roughly one boot in sixteen — now reproducible on demand, 3019 times a boot. Written up in io_read_contract.md, with the recipe recorded in arch_hw.c beside the constant somebody would change.


Closed

The windows are 64 KiB each and P4 step 3 is done. What held it up was not a use-after-free: vibeos_frame_init never initialised the descriptor flags, so the poison detector judged virgin frames and reported 3019 corruptions in a boot with none. The staging sizes only decided where that descriptor table landed in physical memory, which is why the symptom tracked them so convincingly.

Written up in mm_frame_init_flags.md, with the measurement that settled it and the two tools that were wrong on the way.