The I219's driver, decided on the host and proven on QEMU's e1000e - #438
Merged
Conversation
The T14's NIC is an Intel I219 at 00:1f.6 and nothing claims it. This is the driver's logic, written under the host-testing ruling from day one: four traits named by function — a register access, a clock, DMA buffers, an interrupt arriving — with everything that branches above them and nothing but the specification's own model below. `stub.rs` is the Intel 82574 GbE Controller Family Datasheet (317694-018, rev 2.7) written down: the register file's side effects, the descriptor rings, and the four latitudes the document gives the hardware that a driver has to be right under — batched and reordered write-back (§7.1.7.1), a head register that counts descriptors "not yet stored in memory" (§7.1.8), null descriptor padding (§7.1.7.2) and the spurious interrupt (§7.4.5), plus §10.2.4.1's case 3 against §7.4.5's blanket read-to-clear, which are two readings of one document. All are on by default and every one takes the seed. The register set is the 82574's, which is why one driver drives both the I219 and QEMU's `e1000e` model — the 82574L itself, 8086:10d3 — and why the sections cited throughout are that datasheet's rather than the I219's own, which describes the part and not the registers. What the datasheet changed about a driver written from memory: `CTRL.ASDE` "must be set to 0b" on this family, the inter-packet gap is 8/2/10 and not 8/8/6, and §4.6.5 says to unmask RXT, RXO, RXDMT and LSC and "there is no reason to enable the transmit interrupts". Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz
…does The driver's four real implementations are `userland/netd/src/i219.rs`: the mapped BAR as one volatile access, `SYS_CLOCK` as one counter read, a `DmaRegion` as the memory both sides reach, and the claim's interrupt record. Two views of one grant, and the split is the boundary — the driver reaches descriptors, netd reaches payloads, and neither reaches the other's, which is what lets every decision live in a crate that forbids `unsafe` at all. netd now names three cards and takes whichever one the manifest gave it. The harness gains an `e1000e` machine, and the NIC leaves the `Virtio` shape: which card a machine has is a dimension of its own, because the machine this project targets has an Intel one and no virtio device at all. `https_tls13_e1000e` is the integration proof and it is the existing TLS judge on the new machine: 320,000 bytes fetched over TLS 1.3 through the Intel driver, through slirp, to a host server, with the SHA-256 compared against the same program built by the host's own std. It agrees byte for byte, and the six refusal arms answer as they do on virtio. What the first run found: the card raised no interrupt at all — `userdev=0` in the irq census — and netd ran on its poll timer alone. §10.2.4.9 says why: `IVAR` "is only valid in MSI-X mode. It defines the allocation of the different interrupt causes to one of the MSI-X vectors", it allocates none at reset, and in that mode only the five causes it maps reach a vector. The driver now writes it and reads it back, so which kind of part this is comes from the part: one that takes the value has the allocation and is masked in its names, and one that answers zero — the I219, which has MSI — keeps the classic causes §4.6.5 names. `userdev=54` on the same test afterwards. `stub.rs` models both parts, and the host test for the first fails with zero messages without the write. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz
Found while writing the I219's driver: `pcidev::bring_up` arms MSI-X or refuses the claim, and the e1000e family's PCH parts are documented as MSI parts. The kernel can already arm MSI — `enable_msi` and `toyos-pci::msi` are both there — and nothing calls it. Filed rather than fixed: it is off this task's path, and what settles it is `lspci -vv` on the laptop's `00:1f.6`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz
Japabu
marked this pull request as ready for review
September 7, 2026 21:53
Japabu
enabled auto-merge
September 7, 2026 21:53
Japabu
disabled auto-merge
September 7, 2026 21:54
The review of #438 came back SEND BACK on the boundary: netd's four implementations were supposed to be one instruction each and one of them carried a three-arm policy with a panic in it, three of them were verbatim copies of virtio_net, and two of the bounds were `debug_assert` beside a neighbour whose bound is real. The trait boundary, as ruled. `Interrupts` now hands the kernel's answer over whole — an associated `Refused` type and the one `IDLE` value that means "nothing since the last read" — so netd's `Claim::taken` is `irq().map(|r| r.count)` and decides nothing. `begin_pass` returns `Result<Pass, I::Refused>`: a claim that refuses for any other reason is the function no longer being this process's, and that is handed up rather than counted as quiet. The one panic is in `Card::begin_pass`, where it serves both drivers. One window, one refusal word, one latch. `userland/netd/src/device.rs` holds what both drivers need: `Window` moved out of virtio_net with its `assert!`s intact and now carrying netd's register and descriptor accesses too, so the release build has a real bound where it had none; `KernelRefused`, which both opening enums hold instead of spelling `(&'static str, SyscallError)` and its Display arm twice; and `Latch`, which is the "say it only on a change" both diagnostics were copying. A slot that belongs to another driver is unrepresentable. `RxSlot` and `Kind` are gone: the receive token holds the driver it came from, `CARDS` carries each card's opener, and the two `panic!("a receive slot from another driver")` arms have nothing left to guard. `Frame` is the receipt for its own buffer — no public index, no `Copy` — so `rx_done`'s out-of-turn assertion is gone with the state it checked, and `tx_reserve` counts a frame longer than a transmit buffer instead of killing the process over it. The IVAR inference is retired. `Model::write` used to drop a write to `0xE4` and answer zero for a part without MSI-X, which is a behaviour with no clause behind it in either datasheet — the stub modelling what the driver needed to be true. §10.2.4.9 defines the register only "in MSI-X mode" and says nothing about what a part outside it answers, so the driver writes it, reads it back, and refuses `NotAccepted` when the part does not take it. The stub's `msix` field, `without_msix` and the second interrupt test go; two fault injectors — `refuses_writes_to` and `reset_never_clears`, named as injectors and not as modelled behaviour — are what the two previously untested `Refusal` arms are reached by. The rest of the code findings. The model's memory is the grant's size, so an address past the real grant faults in the stub as it would at the unit. Nine unread register constants and `rctl::LPE` are deleted, as are `I219::msix`, `I219::tx_free` and the `pub` on `parse_rx`/`RxRefusal`. The `Nic` match in `qemu.rs` spells its three arms rather than defaulting a new one to virtio. The `ALL_CONFIGS` row is indented and sorted like its fifteen siblings. Two assertions over a compile-time constant are gone; six tests are added. Prose, deleted rather than rewritten: the false "every function below is one instruction" header, the `extern crate std` narration, the QEMU investigation story at the `IVAR` write, a comment whose two numbers were both wrong, the nine-line story on `spurious`, the sentence `report` said twice, the "unreachable by construction" comment on a panic that no longer exists, the third and fourth copies of the `Nic` justification in `qemu.rs`, and the stale history paragraph in `tests/netcase/system.toml`. `regs.rs` is no longer more comment than code. The filed issue is a `finding` and not a `defect` — its own body says it has not been read off the laptop — and its heading is now its slug. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz
Handing the kernel's word up whole took `WouldBlock` with it, and `Card::begin_pass` panicked netd on the ordinary "nothing since the last read": `FAIL https_tls13` on the virtio arm, with `netd::main` in the backtrace, while the e1000e arm passed because `toyos-i219` already answers that one. The virtio driver decides its own idle, as it always did. What it no longer carries is the second copy of the panic and the three sentences above it: a refusal that is not `WouldBlock` goes up as the kernel worded it, and `Card::begin_pass` is where both drivers die of it. `cargo test https_tls13`: PASS https_tls13 (4s), PASS https_tls13_e1000e (4s), sha256 167c997971bd50fc8083ec2d09b8f5568ce555090ad35f6b2174237bb14f4d15 on all four arms. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz
`Window::len` was the one accessor in netd that answered a size under a different name from `Registers::bytes` and `DmaBuffers::bytes`, which both forward to it. It is `bytes` now, at its three call sites. The driver boundary the review asked to place is filed rather than invented: `issues/design-debt/a-userland-device-driver-has-no-shared-boundary-to-be-written-against.md` records that `toyos-i219`'s four traits have one implementation each, that two of them are shaped by a legacy e1000 descriptor and fit no xHCI TRB or NVMe entry, and that nothing is owed until a second userland driver exists to be written against them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz
The registration went in carrying the UNMEASURED bootstrap marker, which `--merge-durations` refuses to let land: `committed UNMEASURED profile marker(s) are provisional and may not land: https_tls13_e1000e`, at `src/durations.rs:244`, on run 34167478261's `durations` job — the run's only red, with all twelve `guest` shards green. The value is the merged profile that job uploaded as `test-durations-merged` on that run: 6,072 ms across twelve shards. `FAST_COMMIT_MS` is 8,000, so the name is priced with margin and keeps `Tier::Fast`. Only its own row moves. `https_tls13` measured 5,120 ms on the same artifact against the 5,166 ms committed, which is run-to-run drift on a name this change left alone and not this commit's to rewrite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz
…last Round 2 found that deleting the out-of-turn assertion left nothing in its place: two receipts given back in reverse walked `RDT` 1 then 0 — handing the device a descriptor whose bytes their holder was still reading, and then the rest of the ring — where the previous revision refused it by name. The reviewer proved it with a scratch test, and `rx_tail` was left a dead store. The guard is back, but not as an ordering rule. `give_back` writes the tail only after walking it forward over descriptors that are ready, stopping at the first that is not and at `rx_next`: §7.1.8's tail "identifies the location beyond the last descriptor hardware can process", so a run is what it may be advanced over and an index is not. Returning out of turn now moves nothing until the older receipt comes back, and then moves once over both. **A refusal by name would not have covered the path that matters.** `poll_rx` gives a refused descriptor back through this same function while the caller may hold a receipt for an earlier one — so the restored assertion would have fired on the driver itself, and a refusal would have had the driver refusing its own call with a receive slot on the floor. That was latent under the old `assert!` and no test reached it. Readiness is read out of the descriptor rather than recorded beside it: `publish_rx` zeroes the status word and a descriptor still held carries the non-zero word the device wrote back, so there is no second record to disagree with the ring. `rx_tail` is read again, so the dead store is gone with it. Two tests, and both red under a mutation that reverts the whole walk to `let tail = index;` (29 passed, 2 failed; green arm restored at 31): `a_buffer_given_back_out_of_turn_does_not_carry_the_tail_over_an_older_one` asserts the tail does not move on the reversed return and then moves to 1 when the older receipt lands, and `a_refused_descriptor_does_not_carry_the_tail_over_a_frame_still_held` asserts the same on the driver's own refusal path — the one an assertion could not have guarded. `issues/design-debt/a-userland-device-driver-has-no-shared-boundary-to-be-written-against.md` loses the clause naming a `toyos-userdev` that does not exist. A design that is not yet written is not yet known. `cargo test -p toyos-i219`: 31 passed. `cargo run -- --clippy`: 5 invocations clean. `cargo test https_tls13`: PASS https_tls13 (8s), PASS https_tls13_e1000e (8s), sha256 167c997971bd50fc8083ec2d09b8f5568ce555090ad35f6b2174237bb14f4d15 on all four arms. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GGQ2H2aCwd1jfiNmjsiUvz
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The ThinkPad T14's NIC is an Intel I219 at
00:1f.6that the kernel enumeratesand nothing claims. This is the driver for it, and the machine that runs it
before the laptop does.
The driver, decided on the host
toyos-i219/is a new pure crate —no_std,forbid(unsafe_code), nodependencies, a member of the host workspace — holding every decision the driver
makes and none of the instructions that carry them out. It is written against
four traits named by function: a register access, a clock, DMA buffers, and what
the claim answered when its interrupt record was read. Each is a generic
parameter resolved at compile time, and no implementation of one decides
anything: netd's
Claim::takenisself.0.irq().map(|record| record.count),and which refusal means "nothing arrived" and which means the function is no
longer this process's is
I219::begin_pass's call, made once and tested on thehost.
The two memory barriers are methods on the DMA-buffer trait rather than a fifth
trait, because the ordering they impose is over that memory: the
architecture's rules about when a store to DMA memory becomes visible to a
device belong in the one-instruction implementation and nowhere above it. On
ARM64 they are the whole of what changes.
Descriptors are the driver's and payloads are netd's, over one grant. The
DMA trait reads and writes 64-bit words, which is exactly a legacy descriptor's
two halves, so the pure crate never needs a pointer to a frame and netd never
needs a descriptor's layout. That split is what makes
forbid(unsafe_code)above and
unsafebelow both honest.What a caller cannot get wrong. A received frame is the receipt for its own
buffer — private index, not
Copy,#[must_use]— andrx_donetakes it byvalue, so there is no index to mismatch and no assertion guarding one. netd's
receive token holds the driver the frame came from rather than a tag saying
which, so a slot and a driver that do not go together is not a state the program
can be in. A frame longer than a transmit buffer is refused and counted, never
panicked on.
The stub, written from the datasheet before the driver
stub.rsis the Intel 82574 GbE Controller Family Datasheet (317694-018, rev2.7) written down and cited clause by clause. It is the 82574's and not the
I219's own because that document defines this register family, and QEMU's
e1000eis the 82574L — which is why one driver takes both parts and why aQEMU machine is evidence about the laptop at all.
It implements what the specification permits and not what the driver expects.
Every latitude is a
Permitsentry, on by default, seeded, and the seed isprinted by every assertion:
are opportunistically written out in cache line-oriented chunks", and the
order inside a chunk is the hardware's.
completed but not yet stored in memory". The frame's bytes travel with the
write-back (§7.1.3.3: "when the DD bit is set along with EOP, the received
packet is completely in main memory"), and the buffer holds a pattern until
then.
DDset "and all other bitsunchanged".
ICRwithno interrupt asserted "has no side affect"; §7.4.5 says all bits clear on a
read. The model takes either and the driver is right under both.
Nothing else in it is a behaviour. Two knobs — a register this part does not
take a write to, and a
CTRL.RSTthat never clears itself — are named as faultinjectors rather than as modelled hardware, and they are how the two refusals
that no working part produces are reached.
What the datasheet changed about a driver written from memory:
CTRL.ASDE"must be set to 0b in the 82574" (§10.2.2.1) where the 8254x habit is
SLU|ASDE; the inter-packet gap is 8/2/10 and not 8/8/6 (§4.6.6); §4.6.5 saysto unmask RXT, RXO, RXDMT and LSC and that "there is no reason to enable the
transmit interrupts";
CTRLcarries two reserved bits documented as set, so thebring-up keeps what it read; and §10.2.4.7 says "if any bits are set in EIAC,
the ICR register should not be read", which this driver does.
The machine that runs it
netd names three cards — the I219
8086:15fc, the 82574L8086:10d3, and thevirtio NIC — and drives whichever one the manifest gave it. The harness gains an
e1000emachine, and the NIC leaves theVirtioshape on the way: which card amachine has is a dimension of its own, because the machine this project targets
has an Intel one and no virtio device at all.
userland/netd/src/device.rsis what both drivers need from the substrate inone place: the bounds-checked volatile
Window— moved out of the virtio driverwith its
assert!s intact, and now carrying the Intel driver's register anddescriptor accesses too — the kernel's word for a refused call, and the latch a
diagnostic is printed on. Neither driver keeps a second one.
https_tls13_e1000eis the integration proof, and it is the existing TLS judgeon the new machine rather than a new assertion: 320,000 bytes fetched over
TLS 1.3 through this driver, through slirp, to the same host server the
differential arm fetches from. Both answer
sha256=167c997971bd50fc8083ec2d09b8f5568ce555090ad35f6b2174237bb14f4d15, andthe six refusal arms answer as they do on virtio.
What the first run found, and what the datasheet would not settle
The card raised no interrupt at all —
userdev=0in the irq census — and netdran on its poll timer alone. §10.2.4.9 says why:
IVAR"is only valid in MSI-Xmode. It defines the allocation of the different interrupt causes to one of the
MSI-X vectors", it allocates none at reset, and in that mode only the five
causes it maps reach a vector at all. The driver writes it, and
userdev=54onthe same test afterwards.
What the driver does not do is branch on the read-back. An earlier revision
took a part that answered zero for one with MSI and no
IVAR— and no clause ineither datasheet says that is what such a part answers. §10.2.4.9 defines the
register only "in MSI-X mode" and is silent about anything else, so a part that
does not take the write is refused by name,
NotAccepted { reg: 0xE4, .. },rather than driven on a guess about which interrupt it would raise instead. If
the T14's I219 is that part, it says so on the first boot, in one line, instead
of going quiet.
The two checks
This is a device change, so it names them.
Negative control. Five whole-mechanism reverts onto the base the green arm
was measured on, each restored afterwards with the green arm re-measured at
29 passed:
RDHfor the completion signal instead ofDDin memoryICRwrite-back, the read taken for the acknowledgementDDcheck when reclaiming a transmit descriptorIVARwriteRCTL.SECRC, so a length is four bytes longer than its frameFAIL https_tls13_e1000eThe first row is the finding worth reading. QEMU's
e1000eemulates DMAsynchronously, so its head register is never ahead of memory and the mutation
the specification forbids passes there. That is the measured case for the
host-testing ruling: the QEMU registration is a negative control for
integration, the host tests are the negative control for the ring and
interrupt logic, and the last row is the mutation that reds both.
Independent oracle. The datasheet, cited section by section and written into
the stub before the driver it tests; and the host's own
std, fetching the samebody from the same port and agreeing on its digest byte for byte.
Measurements
cargo test -p toyos-i219: 29 passed, 0.02 s, no QEMU — everyRefusalarm hasa test.
cargo test https_tls13:PASS https_tls13 (4s),PASS https_tls13_e1000e (4s).cargo test --lib: 259 passed, 1 ignored.cargo test --workspace --exclude toyos-build: 136 suites, exit 0.cargo run -- --clippy: 5 invocations clean, exit 0.https_tls13_e1000eispriced at 6,072 ms across twelve shards, off the
test-durations-mergedartifact run 34167478261's
durationsjob uploaded — underFAST_COMMIT_MS(8,000), so it keeps
Tier::Fastwith margin. The whole QEMU harness —cargo test— is331 passed, 1 failed, 332 total (454.6s), 92 held back for the nightly tier; the one red istlb_shootdown_waits, whichcargo run -- --known-redanswers KNOWN-RED and which the run itself classifiedALONE: GREEN — it fails only beside other guests. The irq census over that runreports 607
userdevinterrupts across 62 guests.What this does not do
No ABI change:
toyos-abi/src,toyos/srcanduserland/libc/srcareuntouched. No T14 run and no metal half — the boot config, DHCP under the
hostname
toyos-t14, the first ping and ssh, and the profile rows wait on themetal suite landing. No DHCP, no PHY or MDIO, no checksum offload, no VLAN, no
RSS, no second queue, no flow control, no statistics counters.
Two things filed rather than fixed.
issues/kernel/a-claimed-function-must-have-msi-x-and-the-i219-may-not.md:pcidev::bring_uparms MSI-X or refuses the claim by name, and the e1000efamily's PCH parts are documented as MSI parts — so the laptop may refuse the
claim before this driver runs. The kernel can already arm MSI (
enable_msi,toyos-pci::msi) and nothing calls it; what settles whether it must islspci -vvon the laptop's00:1f.6.issues/design-debt/a-userland-device-driver-has-no-shared-boundary-to-be-written-against.md:these four traits have one implementation each and two of them are shaped by a
legacy e1000 descriptor, so whether there is one boundary every userland driver
is written against is undecided — and nothing is owed until a second such driver
exists.
🤖 Generated with Claude Code