Skip to content

The I219's driver, decided on the host and proven on QEMU's e1000e - #438

Merged
Japabu merged 8 commits into
mainfrom
wt/toyos-i219
Sep 8, 2026
Merged

The I219's driver, decided on the host and proven on QEMU's e1000e#438
Japabu merged 8 commits into
mainfrom
wt/toyos-i219

Conversation

@Japabu

@Japabu Japabu commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

The ThinkPad T14's NIC is an Intel I219 at 00:1f.6 that the kernel enumerates
and 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), no
dependencies, 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::taken is self.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 the
host.

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 unsafe below both honest.

What a caller cannot get wrong. A received frame is the receipt for its own
buffer — private index, not Copy, #[must_use] — and rx_done takes it by
value, 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.rs is the Intel 82574 GbE Controller Family Datasheet (317694-018, rev
2.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
e1000e is the 82574L — which is why one driver takes both parts and why a
QEMU machine is evidence about the laptop at all.

It implements what the specification permits and not what the driver expects.
Every latitude is a Permits entry, on by default, seeded, and the seed is
printed by every assertion:

  • Batched and reordered write-back (§7.1.7.1): descriptors "accumulate and
    are opportunistically written out in cache line-oriented chunks", and the
    order inside a chunk is the hardware's.
  • A head register ahead of memory (§7.1.8): it "includes those descriptors
    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.
  • Null descriptor padding (§7.1.7.2): DD set "and all other bits
    unchanged".
  • The spurious interrupt (§7.4.5), by that name.
  • Two readings of one document: §10.2.4.1's case 3 says a read of ICR with
    no 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.RST that never clears itself — are named as fault
injectors 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 says
to unmask RXT, RXO, RXDMT and LSC and that "there is no reason to enable the
transmit interrupts"; CTRL carries two reserved bits documented as set, so the
bring-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 82574L 8086:10d3, and the
virtio NIC — and drives whichever one the manifest gave it. The harness gains an
e1000e machine, and the NIC leaves the Virtio shape on the way: 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.

userland/netd/src/device.rs is what both drivers need from the substrate in
one place: the bounds-checked volatile Window — moved out of the virtio driver
with its assert!s intact, and now carrying the Intel driver's register and
descriptor 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_e1000e is the integration proof, and it is the existing TLS judge
on 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, and
the 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=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 at all. The driver writes it, and userdev=54 on
the 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 in
either 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:

mutation host QEMU
RDH for the completion signal instead of DD in memory 28 passed, 1 failed passes
no ICR write-back, the read taken for the acknowledgement 28 passed, 1 failed not measured
no DD check when reclaiming a transmit descriptor 28 passed, 1 failed not measured
no IVAR write 27 passed, 2 failed not measured
no RCTL.SECRC, so a length is four bytes longer than its frame 21 passed, 8 failed FAIL https_tls13_e1000e

The first row is the finding worth reading. QEMU's e1000e emulates DMA
synchronously, 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 same
body 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 — every Refusal arm has
a 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_e1000e is
priced at 6,072 ms across twelve shards, off the test-durations-merged
artifact run 34167478261's durations job uploaded — under FAST_COMMIT_MS
(8,000), so it keeps Tier::Fast with margin. The whole QEMU harness —
cargo test — is 331 passed, 1 failed, 332 total (454.6s), 92 held back for the nightly tier; the one red is tlb_shootdown_waits, which
cargo run -- --known-red answers KNOWN-RED and which the run itself classified
ALONE: GREEN — it fails only beside other guests. The irq census over that run
reports 607 userdev interrupts across 62 guests.

What this does not do

No ABI change: toyos-abi/src, toyos/src and userland/libc/src are
untouched. 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 the
metal 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_up arms MSI-X or refuses the claim by name, and the e1000e
family'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 is
lspci -vv on the laptop's 00: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

Japabu and others added 3 commits September 7, 2026 23:24
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
Japabu marked this pull request as ready for review September 7, 2026 21:53
@Japabu
Japabu enabled auto-merge September 7, 2026 21:53
@Japabu
Japabu disabled auto-merge September 7, 2026 21:54
Japabu and others added 5 commits September 8, 2026 00:33
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
@Japabu
Japabu enabled auto-merge September 7, 2026 23:34
@Japabu
Japabu added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 4b36027 Sep 8, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant