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.
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.
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.
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.
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.
[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.
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.
- 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.
The callers still use the old three-function API. fat.c reaching the disk
directly, and the three parallel read paths, are I2.