Repository navigation
VT-d: a moved context entry is invalidated under the domain it held, in one 16-byte write, and GCMD follows §11.4.4.1 - #700
Conversation
…and GCMD follows §11.4.4.1 Moving a function's context entry onto another domain invalidated the context cache and the IOTLB under the domain it was moving TO. VT-d Rev. 4.0 §6.5.2.1 invalidates a device-selective context-cache entry only where both the source-id and the domain-id match, and §6.2.1 has the IOTLB look a translation up by requester while keeping it under the domain it was made in. So the entry the unit held for the old domain, and every translation the function made through it, survived the move: a function released from slot 0, whose slot G then claimed, re-claimed into slot 1 could still translate through slot 0's domain, which now maps G's grants. QEMU ignores the domain-id of a device-selective context invalidation (intel_iommu.c, vtd_process_context_cache_desc), so no guest could see it. Linux reads the id off the entry it replaces (domain_context_clear_one, intel_context_flush_no_pasid) and so does this now: `table::bind` reads the entry, `rebind` decides the new entry and a `Displaced` that only the old entry's id goes into, and `Queue::invalidate_context` takes nothing else. A const block holds `rebind` to it: a move off the identity domain onto domain 0xffff is invalidated under domain 1. The entry itself was rewritten as two 8-byte stores, the domain-id half first, which leaves a present entry naming the new id over the old table. §6.2.2.1 asks a present entry to change by a 16-byte atomic write or through states each self-consistent; it now changes in one `lock cmpxchg16b`, which also proves the entry replaced is the one whose id is invalidated. Every GCMD write started from a shadow that began at 0, so the first one (`QIE`) also dropped any `TE`, `IRE` or `CFI` firmware had left on, several fields in one write where §11.4.4.1 asks one. `Unit::command` is now the section's recipe: GSTS & 0x96FFFFFF with one field changed. What firmware left on is then switched off deliberately, one field per write, before the unit is pointed anywhere, because §6.6 and §6.7 let a live unit's tables move only onto tables that translate alike and §6.5.2 sets a queue up only while queued invalidation is off: translation, compatibility-format pass-through (while GSTS still reports it, §11.4.4.2), interrupt remapping, then queued invalidation once its queue is empty. Linux does the same for a handover it cannot copy (init_dmars, dmar_disable_qi, intel_setup_irq_remapping). The unit line now carries GSTS as firmware left it. `iommu-firmware-left` stages that handover — every unit queueing, translating and remapping through tables of its own, as this kernel leaves one — and the selftests boot's `iommu_firmware_left` row judges it on the T14: every unit that translates said it switched each of those off first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8
Found while moving context entries: release resets the function and clears its bus mastering, and leaves its context entry naming the slot's domain, which the slot's next holder is attached to. Not fixed on this branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8
|
Probe and mutation patches behind the checks in the body, each applied with Trace probe (
|
|
T14 selftests boot at
What the boot's log shows about the hand-over:
As the body says, this boot is no oracle for the stale-context escape itself (§6.5.2.1 lets hardware invalidate more coarsely than asked). No record row was written; the tree is clean. |
|
Review round 1, head Net: 9 files, +283/−84 (+199). Production kernel +215/−84: Merge with Checked and holding:
BLOCKER
NOTE
REMOVENone. SEND BACK |
Answers review round 1 of #700. `hand_over` ran before the identity domain and the unit's context entries were built, so a unit firmware left translating passed DMA untranslated for the whole build: on the T14 under `iommu-firmware-left`, unit0's TE went off at 0.153 s and came back at 0.155 s around the 8956-leaf build. It now runs after the build and before `Queue::new` writes IQA (VT-d §6.5.2 points a queue only with queued invalidation off), outside the TABLES lock. Measured with the hand-over probe on QEMU 11.1.1: `identity domain ... covers` at 0.082 s, unit0's hand-over at 0.083 s, `translating` at 0.090 s; at 55a5ccb the hand-over was at 0.088 s and the build at 0.092 s. What stays open is filed: the window that remains while a unit firmware left translating is programmed, and a unit this kernel does not program keeping whatever firmware left on. The iommu_firmware_left judge reads `ir` through `unit_fields`, the parse tests/common/iommu.rs already has for that line, and refuses a translating unit no line describes. The released-function issue says what is true of `bind`: no release reaches it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8
|
Probe behind round 2's BLOCKER 1 measurement: round 1's hand-over probe, unchanged, applied with --- a/tests/common/iommu.rs
+++ b/tests/common/iommu.rs
@@ -215,13 +215,14 @@ fn declining_is_not_free(test_config: &Path) -> Result<(), String> {
&[],
BootOptions {
profile: Profile::Headless,
- kernel_params: &["virtio-no-access-platform"],
+ kernel_params: &["virtio-no-access-platform", "iommu-firmware-left"],
..Default::default()
},
);
let log = Serial::boot(&qemu);
log.must_be_clean()?;
log.must_say("Boot: complete")?;
+ crate::iommu_firmware_left(log.text())?;
let refused: Vec<&str> = log
.text()
The same probe at |
|
Round 1's collapse mutation re-run at --- a/kernel/src/arch/x86_64/vtd/mod.rs
+++ b/kernel/src/arch/x86_64/vtd/mod.rs
@@ -359,7 +359,7 @@ impl Unit {
self.regs.read_u64(IQH_REG) == self.regs.read_u64(IQT_REG)
});
}
- self.command(bit, false, what);
+ self.regs.write_u32(GCMD_REG, 0);
}
}
}
|
|
T14 run at Image sha256 So with the hand-over moved after the table build (BLOCKER 1's fix), the first thing that happens once |
… identity domain The T14's selftests boot at 9be0a8a panicked at 0.156 s on a read fault from the integrated GPU (00:02.0, reason 0x06, 0x9c855000, inside rmrr0) once unit0 translated; at 55a5ccb the same boot passed all ten rows. `leave_on` left each unit translating through an empty root table: every request from every function behind it faults, the GPU's scanout out of its reserved region included, which no firmware leaves a unit doing (a reserved region stays mapped while translation is on). At 55a5ccb that state lasted from `leave_on` to `hand_over`, microseconds; with the hand-over after the table build (review round 1, BLOCKER 1) it lasts the 8956-leaf build, 0.153 s to 0.156 s on the T14, with the GPU faulting the whole time. The unit's fault records overflowed in it, and the fault that killed the boot came on the GPU's next scanout read once unit0 translated through this kernel's tables. `leave_on` now builds an identity domain of its own and gives every enumerated function a context entry naming it, as this kernel leaves a unit, so the state the actuator stages is one firmware may hand over and the hand-over stays after the build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8
|
T14 run at Image sha256 |
|
Review round 2, head Round 1's BLOCKERs:
Round 1's NOTEs 4–7 are answered in Net: 12 files, +376/−103. Production kernel is +247/−102, of which BLOCKER
NOTENone. REMOVENone. SEND BACK |
… and one root builder Review round 2, BLOCKER 3: `leave_on` repeated `enable`'s identity-domain build and its per-function `bind_identity` loop, and built a fresh 8956-leaf domain for every unit, widening the T14's build window from 0.153-0.156 s to 0.153-0.158 s. `enable` now looks up (or builds) the per-width identity domain before the actuator runs and hands it to `leave_on`; both take their root table from one function, `identity_root`, which allocates a root and gives every enumerated function a context entry in that domain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8
|
Round 4: the diff --git a/kernel/src/arch/x86_64/vtd/fault.rs b/kernel/src/arch/x86_64/vtd/fault.rs
index 3de98c0f3..4f4425fb7 100644
--- a/kernel/src/arch/x86_64/vtd/fault.rs
+++ b/kernel/src/arch/x86_64/vtd/fault.rs
@@ -176,6 +176,7 @@ impl Records {
/// translation is enabled.
pub fn arm(index: usize, regs: Mmio, found: Records, vector: u8) {
let count = found.count;
+ probe(index, regs, found, "fault::arm, before it clears");
// Clear records firmware may have left behind before unmasking.
for i in 0..count {
@@ -316,6 +317,33 @@ fn drain(index: usize, regs: Mmio, records: u64, count: u32) -> usize {
seen
}
+/// PROBE (review round 2, BLOCKER 1): FSTS, FECTL, GSTS and every fault
+/// record, raw and decoded, without clearing anything.
+pub fn probe(index: usize, regs: Mmio, found: Records, when: &str) {
+ log!(
+ "iommu-probe: unit{index} {when}: fsts={:#010x} fectl={:#010x} gsts={:#010x} nfr={}",
+ regs.read_u32(FSTS_REG),
+ regs.read_u32(FECTL_REG),
+ regs.read_u32(super::GSTS_REG),
+ found.count
+ );
+ for i in 0..found.count {
+ let record = found.offset + i as u64 * 16;
+ let low = regs.read_u64(record);
+ let high = regs.read_u64(record + 8);
+ log!(
+ "iommu-probe: unit{index} {when}: record{i} lo={low:#018x} hi={high:#018x} f={} \
+ source={} addr={:#018x} access={} reason={:#04x} {}",
+ yn(high & (1u64 << 63) != 0),
+ StreamId::pci((high >> 8) as u8, ((high >> 3) & 0x1F) as u8, (high & 0x7) as u8),
+ low & !0xFFF,
+ if high & (1u64 << 62) != 0 { "read" } else { "write" },
+ ((high >> 32) & 0xFF) as u8,
+ reason_name(((high >> 32) & 0xFF) as u8),
+ );
+ }
+}
+
fn clear_record(regs: Mmio, record: u64) {
regs.write_u32(record + 12, RECORD_FAULT);
}
diff --git a/kernel/src/arch/x86_64/vtd/mod.rs b/kernel/src/arch/x86_64/vtd/mod.rs
index a57385dba..f698b7d2f 100644
--- a/kernel/src/arch/x86_64/vtd/mod.rs
+++ b/kernel/src/arch/x86_64/vtd/mod.rs
@@ -559,6 +559,7 @@ fn enable(
queue.invalidate_all(unit.regs);
unit.command(TRANSLATION_ENABLE, true, "translation");
+ fault::probe(index, unit.regs, records, "TE confirmed");
if remap.is_some() {
unit.command(interrupt::INTERRUPT_REMAPPING_ENABLE, true, "interrupt remapping");
} |
|
T14 runs for round 4 ( 1. Probe boot ( Then, from the loader log's saved panic: So: before 2. Head boot ( Readbacks: |
…fter the hand-over Review round 2's BLOCKER 1 on pull request #700. The T14 probe boot at 9be0a8a (FSTS probe) shows unit0's one record, before fault::arm clears it, is 00:02.0 reading 0x9cb80000 with reason 0x01 and FSTS=0x3: the empty root's refusals, overflowed. Once TE is confirmed FSTS is 0 and the record is clear, so the overflow and the fatal 0x06 at 0x9cc39000 that follow were raised through this kernel's identity domain, which grants the read. Against VT-d Rev. 4.1: 0x06 is LGN.3, a read meeting a second-stage entry without R. Every entry on the walk grants it. The context entry is TT=00, AW=2, DID=1 in every arm. A CM=0 unit caches no faulted translation (§6.2.4.1), and the SRTP is followed by acknowledged global context-cache and IOTLB invalidations (§6.6). Nothing the spec describes carries the refusal across TE off and on. 55a5ccb (refused only within the hand-over's millisecond, TE off for 2 ms) and 25ab31b/5e8cbb06e (no refusal) did not fault. Firmware leaves the T14's units off, and today's actuator refuses nothing, so the state is no longer reached. It is filed with the reproduction as its exit; two probe images at 9be0a8a (raw record, PCI STATUS, the walk as DRAM holds it, and a 3 ms TE-off arm) are staged for the T14. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8
|
Round 5: two probe images for the walk (
What it decides:
diff --git a/kernel/src/arch/x86_64/vtd/fault.rs b/kernel/src/arch/x86_64/vtd/fault.rs
index 3de98c0f3..0f92e1294 100644
--- a/kernel/src/arch/x86_64/vtd/fault.rs
+++ b/kernel/src/arch/x86_64/vtd/fault.rs
@@ -176,6 +176,7 @@ impl Records {
/// translation is enabled.
pub fn arm(index: usize, regs: Mmio, found: Records, vector: u8) {
let count = found.count;
+ probe(index, regs, found, "fault::arm, before it clears");
// Clear records firmware may have left behind before unmasking.
for i in 0..count {
@@ -279,6 +280,19 @@ fn drain(index: usize, regs: Mmio, records: u64, count: u32) -> usize {
// First, before anything that can be slow or say no: a function that
// cannot master the bus raises no second fault, which is the whole of
// the ceiling on a storm.
+ log!(
+ "iommu-probe: unit{index} drain: fsts={status:#010x} lo={:#018x} hi={high:#018x} \
+ at={} pp={} exe={} priv={} t2={} pv={:#x}",
+ regs.read_u64(record),
+ (high >> 60) & 3,
+ (high >> 31) & 1,
+ (high >> 30) & 1,
+ (high >> 29) & 1,
+ (high >> 28) & 1,
+ (high >> 40) & 0xF_FFFF
+ );
+ probe_config(stream, "drain, before BME is cleared");
+ probe_walk(index, regs, stream, address);
let stopped = stop(stream);
let seen_here = note(stream);
latch(index, stream, address, reason);
@@ -316,6 +330,99 @@ fn drain(index: usize, regs: Mmio, records: u64, count: u32) -> usize {
seen
}
+/// PROBE (review round 2, BLOCKER 1): FSTS, FECTL, GSTS and every fault
+/// record, raw and decoded, without clearing anything.
+pub fn probe(index: usize, regs: Mmio, found: Records, when: &str) {
+ log!(
+ "iommu-probe: unit{index} {when}: fsts={:#010x} fectl={:#010x} gsts={:#010x} nfr={}",
+ regs.read_u32(FSTS_REG),
+ regs.read_u32(FECTL_REG),
+ regs.read_u32(super::GSTS_REG),
+ found.count
+ );
+ for i in 0..found.count {
+ let record = found.offset + i as u64 * 16;
+ let low = regs.read_u64(record);
+ let high = regs.read_u64(record + 8);
+ log!(
+ "iommu-probe: unit{index} {when}: record{i} lo={low:#018x} hi={high:#018x} f={} \
+ source={} addr={:#018x} access={} reason={:#04x} {}",
+ yn(high & (1u64 << 63) != 0),
+ StreamId::pci((high >> 8) as u8, ((high >> 3) & 0x1F) as u8, (high & 0x7) as u8),
+ low & !0xFFF,
+ if high & (1u64 << 62) != 0 { "read" } else { "write" },
+ ((high >> 32) & 0xFF) as u8,
+ reason_name(((high >> 32) & 0xFF) as u8),
+ );
+ }
+}
+
+/// PROBE (round 5): one 8-byte word as DRAM holds it, the line written back
+/// and invalidated first, so the read is what a non-snooping walk sees.
+fn probe_phys(phys: u64) -> u64 {
+ let ptr = DirectMap::from_phys(phys).as_ptr::<u64>();
+ // SAFETY: probe only; `phys` is a remapping-table word the direct map covers.
+ unsafe {
+ core::arch::asm!("clflush [{a}]", "mfence", a = in(reg) ptr, options(nostack, preserves_flags));
+ core::ptr::read_volatile(ptr)
+ }
+}
+
+const PROBE_ADDR: u64 = 0x000F_FFFF_FFFF_F000;
+
+/// PROBE (round 5): the unit's walk for `stream` at `address`, from the root
+/// RTADDR names, every entry as DRAM holds it.
+pub fn probe_walk(index: usize, regs: Mmio, stream: StreamId, address: u64) {
+ let rta = regs.read_u64(super::RTADDR_REG);
+ let root = rta & PROBE_ADDR;
+ let rlo = probe_phys(root + stream.bus() as u64 * 16);
+ let rhi = probe_phys(root + stream.bus() as u64 * 16 + 8);
+ let ctx = rlo & PROBE_ADDR;
+ let clo = probe_phys(ctx + stream.devfn() as u64 * 16);
+ let chi = probe_phys(ctx + stream.devfn() as u64 * 16 + 8);
+ log!(
+ "iommu-probe: unit{index} walk {stream} @{address:#x}: rtaddr={rta:#x} root[{}]={rlo:#018x}:{rhi:#018x} \
+ ctx[{:#x}]={clo:#018x}:{chi:#018x} p={} tt={} aw={} did={}",
+ stream.bus(),
+ stream.devfn(),
+ clo & 1,
+ (clo >> 2) & 3,
+ chi & 7,
+ (chi >> 8) & 0xFFFF
+ );
+ if rlo & 1 == 0 || clo & 1 == 0 {
+ return;
+ }
+ let mut table = clo & PROBE_ADDR;
+ let mut level = (chi & 7) + 2;
+ while level >= 1 {
+ let slot = (address >> (12 + 9 * (level - 1))) & 0x1FF;
+ let entry = probe_phys(table + slot * 8);
+ log!("iommu-probe: unit{index} walk level{level} table={table:#x}[{slot:#x}]={entry:#018x}");
+ if entry & 3 == 0 || (level == 2 && entry & 0x80 != 0) || level == 1 {
+ return;
+ }
+ table = entry & PROBE_ADDR;
+ level -= 1;
+ }
+}
+
+/// PROBE (round 5): `stream`'s PCI COMMAND and STATUS, read without writing.
+pub fn probe_config(stream: StreamId, when: &str) {
+ let Some(slot) = find(u32::from(stream.requester())) else {
+ log!("iommu-probe: {stream} {when}: not enumerated");
+ return;
+ };
+ let word = window_of(slot.config.load(Ordering::Relaxed), CONFIG_WINDOW).read_u32(4);
+ log!(
+ "iommu-probe: {stream} {when}: command={:#06x} status={:#06x} rma={} rta={}",
+ word & 0xFFFF,
+ word >> 16,
+ (word >> 29) & 1,
+ (word >> 28) & 1
+ );
+}
+
fn clear_record(regs: Mmio, record: u64) {
regs.write_u32(record + 12, RECORD_FAULT);
}
diff --git a/kernel/src/arch/x86_64/vtd/mod.rs b/kernel/src/arch/x86_64/vtd/mod.rs
index a57385dba..a29412e0a 100644
--- a/kernel/src/arch/x86_64/vtd/mod.rs
+++ b/kernel/src/arch/x86_64/vtd/mod.rs
@@ -503,6 +503,7 @@ fn enable(
let Plan { width, records } = plan;
#[cfg(feature = "boot-actuators")]
if crate::actuator::iommu_firmware_left() {
+ fault::probe_config(StreamId::pci(0, 2, 0), "before the actuator");
unit.leave_on();
}
// Before any table is built: what a domain of a driver's own can be is the
@@ -539,6 +540,7 @@ fn enable(
// untranslated only while it is programmed
// (`issues/kernel/a-unit-left-translating-passes-dma-untranslated-while-programmed.md`),
// and before the queue is pointed, which §6.5.2 does only with it off.
+ fault::probe_config(StreamId::pci(0, 2, 0), "before the hand-over");
unit.hand_over();
let mut queue = Queue::new(&mut TABLES.lock(), unit.regs);
// Outside the `TABLES` lock: `interrupt::arm` takes its own lock and
@@ -559,6 +561,11 @@ fn enable(
queue.invalidate_all(unit.regs);
unit.command(TRANSLATION_ENABLE, true, "translation");
+ fault::probe(index, unit.regs, records, "TE confirmed");
+ if index == 0 {
+ fault::probe_config(StreamId::pci(0, 2, 0), "TE confirmed");
+ fault::probe_walk(index, unit.regs, StreamId::pci(0, 2, 0), 0x9cc39000);
+ }
if remap.is_some() {
unit.command(interrupt::INTERRUPT_REMAPPING_ENABLE, true, "interrupt remapping");
}gap ( @@ -539,7 +540,14 @@ fn enable(
fault::probe_config(StreamId::pci(0, 2, 0), "before the hand-over");
unit.hand_over();
+ // PROBE (round 5, gap arm): translation held off as long as 55a5ccb42's build held it.
+ let until = crate::clock::nanos_since_boot() + 3_000_000;
+ while crate::clock::nanos_since_boot() < until {
+ core::hint::spin_loop();
+ }
+ log!("iommu-probe: unit{index} held with translation off for 3 ms after the hand-over");What it decides:
|
|
T14 runs for round 5 ( Each image's sha256 checked in the command that flashed: walk 1. walk ( 2. gap (walk + 3 ms with TE off after each hand-over): passed ( 3. head ( Readbacks: |
|
Review round 3, head Round 2's BLOCKERs:
Net lines,
At the head (
BLOCKER
NOTE
REMOVE
SEND BACK |
…and its exit refuses a timed hold The walk probe read the unit's tables from DRAM at the fault: every entry grants the read, the record is a plain untranslated read and the GPU saw no abort. The gap probe, 3 ms with TE off after the hand-over, booted green. That replaces the staged-not-run paragraph and the leaf claim made by reading. A timed hold is a flat wait no document mandates and widens the untranslated window already filed, so the exit asks for a hand-over that waits on an event instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8
|
Review round 4, head Round 3's BLOCKER:
Round 3's NOTEs and REMOVE:
The brief's question: do the image, guest-suite and kernel checks still stand on What holds:
What does not hold:
BLOCKER
NOTENone. REMOVE
Net lines,
SEND BACK |
|
Review round 5, head Round 4's BLOCKER:
Round 4's REMOVE:
Other claims in the body that I checked:
Since round 4 the code is unchanged; only the body was edited. Net lines, BLOCKERNone. NOTENone. REMOVENone. LAND |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8
A function's context entry moved onto another domain is now invalidated under the domain it held, and replaced in one 16-byte write; every
GCMDwrite is VT-d §11.4.4.1's recipe, and what firmware left on is switched off one field at a time once the unit's tables are built, just before the unit is programmed.Spec read: Intel VT-d Rev. 4.1, D51397-016 (sha256
b0c122a5fa927578ff963636dd3abe87aed0a28f73bbf50e74dc54c099bf4aeb). The tree cites Rev. 4.0 (D51397-015); 4.1's revision history changes none of the sections cited below.What changed, per decision
The invalidation names the old domain id (
vtd/table.rsrebind/Displaced,vtd/queue.rsinvalidate_context,vtd/mod.rsLive::attach). §6.5.2.1 invalidates a device-selective context-cache entry only where source-id and domain-id match, and §6.2.1 Table 15 has the IOTLB look a translation up by source-id while keeping it under the domain it was made in.attachnamed the domain the entry was moving to in both descriptors, so the cached old entry and the function's translations through it survived the move: F released from slot 0, G claims slot 0, F re-claimed into slot 1 may still translate through slot 0's domain, which maps G's grants. Nowbindreads the entry it replaces,rebind(const) returns the new entry and aDisplacedbuilt from the old entry'sDIDonly, andQueue::invalidate_contexttakes nothing but aDisplaced. The IOTLB half also names the old id; nothing is invalidated under the new one, because a translation cached under it for this requester can exist only from an earlier stint there, which that move's own old-id invalidation removed.A present entry changes in one
lock cmpxchg16b(Table::replace_pair). §6.2.2.1 requires a 16-byte atomic write or a sequence whose every state is self-consistent; the two 8-byte stores wrote theDIDhalf first, a present entry naming the new id over the old table. The compare also asserts that the entry replaced is the one whose id is invalidated. No CPUID check is added forCX16: every CPU with a VT-d unit has it, QEMU'sqemu64declares it (target/i386/cpu.c, v9.2.0 l. 2495), and a CPU without it takes#UDat the first move.bindnow refuses (panics) a stream with no present entry:bind_identitygives every enumerated function one beforeTE, and only enumerated functions are attached (pcidev::claimsearchesMACHINE.functions, the same listiommu::initbinds).GCMDfollows §11.4.4.1 (Unit::command):GSTS & 0x96FFFFFFwith one field changed. The shadowgcmdstarted at 0, so the first write (QIE) also cleared anyTE,IREorCFIfirmware left — several fields in one write, which the section forbids.What firmware left on is switched off one field per write, after the unit's tables are built and before its queue is pointed (
Unit::hand_over,FIRMWARE_LEFT, called inenableafteridentity_rootand beforeQueue::new, outside theTABLESlock): translation, compatibility-format pass-through, interrupt remapping, then queued invalidation onceIQH == IQT. Kept on, the rest of the sequence would break §6.6 and §6.7 (a live unit's tables may move only onto tables that translate identically) and §6.5.2 (a queue is set up while queued invalidation is off), andCFIwould ride into this kernel'sIRE.CFIgoes beforeIREbecause §11.4.4.2 reportsCFISonly while remapping is on. After the build, because a unit firmware left translating passes DMA untranslated from the hand-over untilTEis back on; that window remains, and is filed. Differential: Linux v6.16 seedsgcmdfromGSTS(dmar.c:1116-1121), disables a firmware queue afterIQT == IQH(dmar_disable_qi), and disables pre-enabledTEandIREoutside kdump (iommu.c:2126-2128,irq_remapping.c:589-593). The unit line now carriesgsts=as firmware left it.iommu-firmware-left(boot actuator,#[cfg(feature = "boot-actuators")]leave_on) leaves every unit queueing, translating and remapping, each batch ended by a wait as this kernel's are, and writesCFIunconfirmed. It translates through the identity domainenableitself uses, never an empty root:enablelooks the per-width domain up (building it once, logged) before the actuator runs and hands it toleave_on, and both take their root from one function,identity_root, which allocates a root and gives every enumerated function a context entry in that domain. The selftests boot carries it; its rowiommu_firmware_leftjudges that every unit that translates said it switched off translation, queued invalidation and, where its unit line readsir=y(throughtests/common/iommu.rs'sunit_fields), remapping; a translating unit no line describes is red. It is insrc/metal.rs'sFLASHABLE: it writes only unit registers a reset returns and tables in memory the boot owns.The T14 red at
9be0a8ac8, and what the probe read.The boot:
9be0a8ac8(actuator pointing each unit at an empty root) panicked at 0.156 s, after unit0 loggedtranslating(orch/vtdctx-r2/metal/selftests/loader.log172–185).unit0 fault recording overflowed, thenDMA FAULT owner=kernel unit0 stream=00:02.0 addr=0x000000009c855000 access=read reason=0x06.rmrr0(0x9c000000..0xa07fffff, scope00:02.0).25ab31bd5(actuator translating through an identity domain), the same boot passed.Round 3's commit message (
25ab31bd5) blamed records that overflowed during the empty-root window, and that account is withdrawn. Review round 2 showed why:fault::armclears every record andFSTSbeforeTEcomes back on.0x01.What the
FSTSprobe read (9be0a8ac8plusprobe-fsts-9be0a8ac8.patch; the orchestrator's T14 run, comment 5974618171;orch/vtdctx-r4/metal-probe/selftests/loader.log177–199):fault::armclears:fsts=0x00000003(PFO,PPF), and the one record is00:02.0reading0x9cb80000with reason0x01. These are the empty root's refusals, overflowed.TE confirmed:fsts=0x00000000, and the record is clear (f=n).unit0 fault recording overflowed, and00:02.0reading0x9cc39000with reason0x06.So the overflow and the
0x06were both raised through this kernel's identity domain.Against Rev. 4.1,
0x06is LGN.3 (Table 26): a read that meets a second-stage entry withoutR. None of the causes the spec allows applies:LEAF_PERM,descend), and each line is written back byclflushbecauseECAP.C=0.TT=00,AW=2,DID=1in every arm.CM=0caches no faulted translation (§6.2.4.1).SRTPis followed by global context-cache and IOTLB invalidations, both acknowledged (§6.6).The same code ran green in two other arms:
55a5ccb42, the refusal and the hand-over were both logged at 0.153 s, andTEstayed off until 0.155 s.25ab31bd5and5e8cbb06e, nothing was refused.What the walk probe read on the T14 (
9be0a8ac8plusprobe-walk-9be0a8ac8.patch, the third red of that actuator;orch/vtdctx-r5/metal-walk/selftests/loader.log135–197,orch/vtdctx-r5/probe-walk-lines.txt):fault::armclears: againfsts=0x00000003and one record,00:02.0reading0x9ccc0000with reason0x01.00:02.0reading0x9cdb7000with reason0x06, rawhi=0xc000000600000010:at=0 pp=0 exe=0 priv=0 t2=0, a plain untranslated read.RTADDR0xa18000, each entry read from DRAM afterclflush, atTEand at the fault alike: root0xa19001, context0xa04001:0x102(p=1 tt=0 aw=2 did=1), L40xa05003, L30xa08003, leaf0x9cc00083, a present 2 MiB read/write entry over0x9cdb7000.STATUSreads0x0010,rma=0 rta=0, before the actuator, before the hand-over, atTEand at the fault.What the gap probe read (the walk patch plus 3 ms with
TEoff after each hand-over,probe-gap-9be0a8ac8.patch;orch/vtdctx-r5/metal-gap/selftests/kernel.log,orch/vtdctx-r5/probe-gap-lines.txt): the same empty-root refusals beforefault::arm(fsts=0x00000003, record0x01), thenunit0 held with translation off for 3 ms after the hand-over(line 123), noDMA FAULTline andBoot: complete (1194ms)(line 344);verdict.txtpassed.Conclusion.
0x06is neither in this kernel's tables nor in any cached state VT-d Rev. 4.1 lets the unit keep.TEback on within microseconds of the hand-over: three boots of the empty-root actuator with no hold faulted, the one with a 3 ms hold did not, and55a5ccb42(TEoff about 2 ms after its refusals) did not.gsts=0x40000000on all four units), and this head's actuator does not do it.origin/main'senable, not run: its firstGCMDwrite (QIE, from a shadow of 0) clears a firmware-leftTE, andTEcomes back on afterSRTP,IRTAand the global invalidations, as fast as this branch's hand-over does. No boot ofmainin a refusing state was run.issues/kernel/a-function-refused-under-translation-faults-read-permission-after-the-hand-over.md.No cheaper tier reaches it. The failure needs a function mastering the bus while the actuator's state is live, and no QEMU guest has one: nothing DMAs between
leave_onand the hand-over there, so the hand-over probe was green at9be0a8ac8and is green now. A host test over the order sees only the order.Filed:
issues/kernel/a-released-function-still-names-its-slots-domain.md— release leaves the function's entry on its slot's domain, which the slot's next holder is attached to.issues/kernel/a-unit-left-translating-passes-dma-untranslated-while-programmed.md— the window between the hand-over andTEback on.issues/kernel/a-unit-this-kernel-does-not-program-keeps-what-firmware-left-on.md— a unitplanrefuses, or pastMAX_UNITS, is never handed over.issues/kernel/a-function-refused-under-translation-faults-read-permission-after-the-hand-over.md— the0x06above.Checks (high-risk: security boundary, device driver)
Oracle: the specification, cited in one clause at each changed site; Linux v6.16 as the differential implementation (
domain_context_clear_one,iommu.c:1719-1738, readsdidoff the entry it clears and hands it tointel_context_flush_no_pasid,pasid.c:1120-1150, for both descriptors); QEMU 11.1.1's own descriptor decode, traced; the T14.Logs are in the orchestrator's scratchpad: round 7's in
orch/vtdctx-r7/, round 6's inorch/vtdctx-r6/, round 5's inorch/vtdctx-r5/, round 4's inorch/vtdctx-r4/, round 3's inorch/vtdctx-r3/, round 2's inorch/vtdctx-r2/, round 1's inorch/vtdctx/. Every probe and mutation patch is in a comment below, each applied withgit apply --check, run, and reverted in the same script.At the head,
041014fdf(origin/main7a6b667edmerged, which brings #703 and with it new crate paths inkernel/Cargo.toml,kernel/pci/Cargo.toml,src/and the userland the image carries; since6fdd9c579the merge and the issue's text):cargo run -- --ci hostHost: 72 step(s), all green(orch/vtdctx-r6/ci-host.log)cargo run -- --build-onlyBoot image: …/target/bootable.img(orch/vtdctx-r7/build-only.log)cargo test --test toyos-build26 passed, 26 total,iommu_virtio_platformamong them (orch/vtdctx-r7/toyos-build.log)cd kernel && cargo checkorch/vtdctx-r7/kernel-check.log)cd kernel && cargo check --features boot-actuatorsorch/vtdctx-r7/kernel-check-actuators.log)At
6fdd9c579(orch/vtdctx-r5/), that commit's runs:cargo run -- --ci hostHost: 69 step(s), all green(ci-host.log)cargo run -- --build-onlybuild-only.log)cargo test --test toyos-build26 passed, 26 total; every x86 guest boots behindintel-iommu(guest-suite.log)cd kernel && cargo checkkcheck.log)cd kernel && cargo check --features boot-actuatorskcheck-act.log)cargo test --test toyos-build -- --metal --metal-readback orch/vtdctx-r5/metal-head iommu_firmware_leftd2dfe4d3b632d1f8940606860e4d46f3cc57ca27e996e8379d01602170039552(metal-stage.log)cargo run --bin toyos-metal -- --image orch/vtdctx-r5/metal-head/selftests/image.img --readback orch/vtdctx-r5/metal-head/selftests --fat32-check, the image's sha256 checked in the flashing command (the orchestrator's run, comment 5974876749)verdict.txtreadspassed; each unittranslating gsts=0xc7000000at 0.155–0.156 s (kernel.log121–136), noDMA FAULTor overflow line,Boot: complete (1154ms)(line 311)cargo test --test toyos-build -- --metal --metal-readback orch/vtdctx-r5/metal-head <row>, once for each ofinput_merge,iommu_firmware_left,lapic_spurious_vector,leak_rollback_selftest,operation_nesting,pci_capability_walk,read_fault_selftests,sysret_ss_reload,xhci_descriptor_walk,xhci_xecp_walk(the orchestrator's, from the clean head)1 registration(s),1 passed, 0 failed, 1 boot(s);iommu_firmware_left:4 unit(s) programmed after switching off what they were handed over with(judge-t14-*.log)9be0a8ac8,probe-walk-9be0a8ac8.patchapplied (comment 5974826699),cd kernel && cargo check --features boot-actuators,test-actuatorsexit 0,cargo test --test toyos-build -- --metal --metal-readback orch/vtdctx-r5/metal-walk iommu_firmware_left, patch revertede997d5802985c67cf1580c4a02eebc640ebc94c9cf07650755aba20ea8784cbc(walk-stage.log)cargo run --bin toyos-metal -- --image orch/vtdctx-r5/metal-walk/selftests/image.img --readback orch/vtdctx-r5/metal-walk/selftests --fat32-check, sha256 checked (the orchestrator's run, comment 5974876749)toyos-metalrefusedverdict.txt:refused, noBoot: completerecord; the panic and the readings above (loader.log135–197)probe-gap-9be0a8ac8.patchintoorch/vtdctx-r5/metal-gap; back to the head afterwards, tree clean3f555e92db15099514a535f9653058d9a4e7a8a0ab3a62561f278f1442c6a2fd(gap-stage.log)cargo run --bin toyos-metal -- --image orch/vtdctx-r5/metal-gap/selftests/image.img --readback orch/vtdctx-r5/metal-gap/selftests --fat32-check, sha256 checked (the orchestrator's run, comment 5974876749)verdict.txtreadspassed; the readings above (kernel.log123, 344)What they decided: the walk grants read in DRAM, so the tables are not what refuses; a 3 ms
TE-off removes the fault, on one boot; the head is green on silicon.At
5e8cbb06e(orch/vtdctx-r4/), whosevtd/the head leaves as it was:declining_is_not_free's boot and the row's judge called on its logidentity domain … coversat 0.094 s, before unit0's hand-over at 0.098–0.102 s,translatingat 0.108 s (probe-fix.log588–592)401d6f7d5identity domain … covers0.092 s,translating0.095 s (round 1'sjudge-fix.log589–594)GCMDwrite of 0, at9be0a8ac8unit0 translates and never said it was handed over with queued invalidation on(orch/vtdctx-r2/nc3.log)FSTSprobe boot (review round 2, BLOCKER 1)9be0a8ac8,probe-fsts-9be0a8ac8.patchapplied (comment below),cargo test --test toyos-build -- --metal --metal-readback orch/vtdctx-r4/metal-probe iommu_firmware_left, patch reverted, back to the head, tree cleanb133204ec52429afdbbd6916056809a731c8499b5d2ac3c5647cf681bc8cd02b; logsFSTS,FECTL,GSTSand the one fault record infault::armbefore it clears them, and again onceTEis confirmed (probe-stage.log)toyos-metalrefused (noBoot: complete)orch/vtdctx-r4/metal-probe/selftests/loader.log177–199)5e8cbb06eon the T14cargo test --test toyos-build -- --metal --metal-readback orch/vtdctx-r4/metal-head <row>for each of the ten rows1 passed, 0 failed;kernel.log116–121: build at 0.155 s, unit0's hand-over andtranslatingat 0.156 s; noDMA FAULTor overflow lineOn the T14, the selftests boot at
25ab31bd5(the orchestrator's run, comment 5974415448; readback inorch/vtdctx-r3/metal/selftests/), the actuator translating through an identity domain and the hand-over after the build:cargo run --bin toyos-metal -- --image orch/vtdctx-r3/metal/selftests/image.img --readback orch/vtdctx-r3/metal/selftests --fat32-check, image sha2561e29e3a2c203be936b2b9a764540aebd82d91239e8e712c69e6a23881843fc95verdict.txtreadspassed;Boot: complete (1164ms)(kernel.log311)cargo test --test toyos-build -- --metal --metal-readback orch/vtdctx-r3/metal <row>, once for each ofpci_capability_walk,read_fault_selftests,leak_rollback_selftest,lapic_spurious_vector,xhci_xecp_walk,xhci_descriptor_walk,sysret_ss_reload,input_merge,operation_nesting,iommu_firmware_left1 registration(s),1 passed, 0 failed, 1 boot(s);iommu_firmware_left:4 unit(s) programmed after switching off what they were handed over with(orch/vtdctx-r3/judge-t14-*.log)kernel.log116–136translating gsts=0xc7000000 tes=y qies=y ires=y cfis=n … eime=ygrep -n "DMA FAULT|overflow" kernel.logWhat it decided: the
9be0a8ac8red does not recur with the hand-over still after the build, so round 1's order is kept; theTE-off gap of the GPU quirk named under Unsure did not fault on this boot. Why9be0a8ac8faulted is theFSTSprobe's reading, above.Negative control on silicon: the selftests boot at
9be0a8ac8(the orchestrator's run;toyos-metalrefused): panic at 0.156 s on the00:02.0read fault above, all ten rows red (orch/vtdctx-r2/metal/selftests/loader.log172–185).At
401d6f7d5, whosetable.rs,queue.rs,Live::attachandhand_overlater rounds leave as they were:iommu_virtio_platformwith-trace vtd_inv_desc_cc_device -trace vtd_inv_desc_iotlb_domain(probe patch)context invalidate device 00:0N.00is followed byiotlb invalidate whole domain 0x1— the identity domain each entry heldorigin/main, same tracegit apply -Rofgit diff origin/main HEAD -- kernel/0x2…0x6, and the guest is green: no QEMU gate sees this defectrebindnamesdomain.idcd kernel && cargo checkevaluation panicked: assertion failed: displaced.domain == KERNEL_DOMAIN(table.rsconst block)On the T14, the selftests boot at
55a5ccb42(the orchestrator's run,toyos-metalexit 0, readback inorch/vtdctx/metal/), the hand-over before the build:cargo test --test toyos-build -- --metal --metal-readback orch/vtdctx/metal <row>, once for each of the ten rows above1 passed, 0 failed, 1 boot(s), the tree clean after each (orch/vtdctx-r2/judge-t14-55a5ccb42.log)kernel.log101–110gsts=0x40000000: only the root table pointer status; firmware leaves nothing on, so on the T14hand_overruns only under the actuatorkernel.log116–136CFISafterCFIis written withIREon andEIMEoff, which QEMU never does (issues/kernel/qemu-passes-compatibility-format-interrupts.md)kernel.log206, 227, 299, 31100:0d.0 moves to domain2,00:14.0 moves to domain3,00:1f.3 moves to domain4, throughcmpxchg16band the old-id descriptors;Boot: complete (1152ms)The T14 is no oracle for the stale-context escape
§6.5.2.1 lets hardware invalidate more coarsely than asked — a unit that drops a device-selective entry by source-id alone never shows the defect — and whether F's old entry is still cached when F next masters the bus is the unit's replacement policy, which nothing here can observe or force. A no-escape on the base kernel would not refute the defect, and a no-escape on the fix would not confirm it, so no row stages it.
Unsure
0x06(filed above). The walk rules out the tables and the GPU's abort bits; the gap shows aTE-off of 3 ms is enough to clear it, on one boot, and does not say which side held it. This GPU's id,9a49, is in Linux v6.16'squirk_igfx_skip_te_disablelist (iommu.c:4502-4504); the hand-over still switches its unit'sTEoff, and the25ab31bd5boot did not fault on that.Growth
git diff --shortstat origin/main...HEAD(merge base7a6b667ed): 13 files, +457/−127. Production kernel: +259/−126 acrossvtd/andactuator.rs(+5);leave_onis compiled only withboot-actuators. Tests and gate:tests/toyos.rs+48,tests/common/iommu.rs+1/−1,src/metal.rs+5. Issues: +144, of which the read-permission fault's is +69.🤖 Generated with Claude Code
https://claude.ai/code/session_01WcU2Dsw6mDYtwYfzVHPzM8