I0 and I1 — the block layer
What was there
Three functions with three shapes — read(lba, buf), read_many(lba, buf, n)
and write(lba, buf) — bound to one global device, each returning a bare
int. The asymmetry was the informative part: there is no write_many, and
nobody had noticed.
I0 — the contract
A request is a value now, with a result that names the reason. Six of them, so
a timeout, an absent device, a medium error, a short transfer, a malformed
request and a range past the end of the disk are six different things instead
of one -1.
This project has already paid for the old shape once. fat_next_cluster
reported a failed table read as the end-of-chain marker — the same value a
healthy last cluster returns — so a flaky sector produced a short file that
said it was complete, and execve parsed whatever the previous program had
left in the shared buffer.
The layer is three things: validate, dispatch, and compare what came back against what was asked for. Only the third is new, and it is the one the above needed: a driver that moves four sectors of eight and returns success is reporting a success that lost half the data.
The bounds check lives here, not in the driver. A driver that checks its own bounds protects the device; it cannot protect a neighbouring partition, whose sectors are perfectly valid addresses on that device. The same argument the swap area makes about the filesystem, one layer down.
A defect the sabotage found in the layer itself
The first version indexed the statistics array with the driver's result
directly. A driver is not trusted to return a value from that enum: an
out-of-range result wrote past the end of the array, and the suite segfaulted
instead of failing an assertion. count_result() bounds it now, and counts an
undefined value as BAD_REQUEST rather than dropping it — a driver returning
something nobody defined is itself worth knowing about.
The same case exposed a test that could not reach what it claimed. Every
refusal path sets a reason explicitly, so removing the reset at the top of
submit changed nothing the tests looked at. The path that needs it is a
driver failing silently, where the layer decides "did the driver say why?" by
asking whether the result is still NOT_ISSUED — and a caller's stale reason
would otherwise be attributed to this request.
I1 — the layer becomes load-bearing
blk.c is an adapter now: everything above still calls
vibeos_x86_64_blk_read, and underneath that is a request through the new
layer.
An adapter rather than a rewrite of the callers, deliberately. fat.c is how
this machine boots and has nine call sites; changing the layer beneath it and
its own calls in one step would mean a failure could be attributed to either.
The callers move later. This makes the new layer load-bearing today, on every
boot, with nothing above it changed.
AHCI learned how big its disk is
It had never issued IDENTIFY, so every AHCI read was unbounded — an LBA past the end of the medium went straight to the controller. The new layer refuses to register a device that cannot state its size, which turned that from an improvement into a prerequisite.
ahci_xfer was generalised into ahci_cmd rather than duplicating forty lines
of FIS construction. Two things that file had already learned the hard way live
in that construction — the PRDT count is one less than the byte count, and
the error bit must be checked inside the wait rather than after it — and a
second copy would have had to learn them again.
LBA48 is read only when word 83 says the 48-bit fields mean anything; otherwise LBA28. A driver that reads them unconditionally gets a plausible number from a disk that never filled them in.
What a boot now says
[IO] MUSTBEZERO medium=0 short=0 timeout=0 bad_request=0 out_of_range=0 register_refused=0
[IO] BLK reads=0x4f95 writes=0x1e sectors_read=0x4f95 sectors_written=0x1e devices=1
Twenty thousand reads and thirty writes, with every error counter at zero. All
six of the first line are asserted; NO_DEVICE deliberately is not, because a
machine with no disk is a configuration rather than a failure.
The counter corrected the plan within minutes of existing
docs/io/README.md stated, as a surveyed fact, that no write is ever
exercised. That was wrong. A boot does thirty sector writes — the shell's
mkdir DOCS reaches vibeos_fs_mkdir, which reaches FAT, which writes both
copies of the table.
The claim had been made by reading code and grepping for callers, and it survived being written into a plan. What caught it was a number. The plan is corrected, and the accurate statement is narrower and still worth acting on: writes reach the medium and nothing verifies that what was written is what comes back.
Verified
- Ten host-test groups for the contract, six sabotage cases in
io-contract.txt, each confirmed red. - Three boot-level cases in
io-blkdev.txt. One of them is recorded as behaving differently from intended: breaking every transfer stops the machine before the counters are printed, so it is indistinguishable from a broken disk — which is arguably the honest outcome. - The gate assertion proved by a request with a null buffer at bind time:
invariant_failed:io_bad_request=1. That case was chosen over breaking a transfer precisely because a sabotage that destroys the thing it is testing proves nothing about the test. - 8/8 clean boots, gcc and clang green.
Not done
The callers still use the old three-function API. fat.c reaching the disk
directly, and the three parallel read paths, are I2.