Skip to content

AArch64 stage 4 on one CPU: the kernel's own tables, the GICv3 and the timer, and user mode - #589

Merged
Japabu merged 19 commits into
mainfrom
wt/toyos-arm64
Sep 29, 2026
Merged

Japabu merged 19 commits into
mainfrom
wt/toyos-arm64

Conversation

@Japabu

@Japabu Japabu commented Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator

Stage 4 of issues/kernel/toyos-runs-on-arm64.md — GIC, timer, MMU and exception levels — on one CPU, ahead of small-kernel stage 6 on the owner's word, with the drivers that track moves out of the kernel refused at the narrowest seam instead of ported. Stages 0–3 landed in #524.

The exit, and where it stands. Stage 4's exit: a user process takes a page fault and a syscall on one CPU; the timer drives preemption; an interrupt storm ends with no lost timer tick; the longest interrupts-off window is measured against x86's; and each of the entry's three EL2 writes (CNTHCTL_EL2, CNTVOFF_EL2, CPTR_EL2) is shown red when deleted. The first three have guest tests (below). Not met by this branch, and recorded in the track's stage 4 as owed: the interrupts-off comparison, which only metal can measure; the three EL2 deletions shown red; the TSC-named handoff (issues/kernel/the-boot-timing-handoff-is-named-for-the-tsc.md); and the instruction-cache, break-before-make and ASID-reclaim claims, which TCG cannot fail on, with the first HVF run as their exit.

What changed, per decision

AArch64 kernel (kernel/src/arch/aarch64/)

  • Page tables (paging.rs). TTBR1_EL1 holds the kernel's direct map: every 4 KiB page firmware's map calls memory the kernel reads, Normal write-back, and nothing else, because a register or a hole mapped Normal is one a speculative access can reach. Registers are mapped only by map_mmio, at 4 KiB exactly, as Device-nGnRE; a page is never retyped. Each user space is its own TTBR0_EL1 root under a 16-bit ASID from toyos-pcid. Live entries are replaced break-before-make with broadcast TLBI, and an executable page is cleaned and invalidated to the instruction stream before it is mapped. AddressSpace::leaf, User copies are scatter-gather: a window is its physical runs, each pinned #593's user-copy query, answers a user address's physical address and the bytes to the end of its 2 MiB block or 4 KiB page; a Write only where the leaf's AP[2:1] grants EL0 read-write, which is the whole walk's answer because no table descriptor this file writes sets APTable.
  • Which pages are memory is decided in toyos-bootmap::aarch64::{direct_map_end, coverage}, pure and host-tested.
  • Control registers (control_regs.rs, the entry): CPACR_EL1.FPEN, CNTKCTL_EL1.EL0VCTEN, TCR_EL1.AS. Nothing of the GIC is touched before the kernel's vectors are installed: ICC_SRE_EL1 is written and read back by irqchip::init. At EL2 the entry writes ICC_SRE_EL2, skipped where ID_AA64PFR0_EL1.GIC names no interface.
  • GICv3 and the timer (irqchip.rs): the distributor, this CPU's redistributor, the CPU interface; only SGIs and the virtual timer's PPI are enabled. Every INTID the kernel names is one Intid enum. The one-shot floor is enforced once, in arm_ticks, the only write of the comparator, as x86's OneShot::ticks enforces it, so no arm and no EL1 re-arm is shorter than MIN_ONE_SHOT.
  • toyos-gicv3, a new pure crate with host tests: the MPIDR affinity packing (cpu::hardware_id and the MADT match both use it), the ICC_SGI1R_EL1 encoding with its RS field for Aff0 ≥ 16, and the redistributor walk with its VLPIS stride — the decisions only CPU 0 exercises in a guest.
  • Per-CPU state (percpu.rs) through TPIDR_EL1, every field an atomic.
  • Exceptions (trap.rs): an SVC to syscall_dispatch; a translation fault from EL0 to process::handle_page_fault; any other EL0 fault to a report and the end of its process; an IRQ to a tick or a kick, which preempts from EL0 and asks for a pass from EL1; anything else from EL1 to a panic. Every return to EL0 runs scheduler::exit_to_user last, and a new thread's first does too, from trampoline_entry directly.
  • Context switch (switch.rs): x19–x30, DAIF and the thread's FP/SIMD state on the outgoing stack. The soft-float kernel never touches FP/SIMD, so it is saved only where a thread stops running. The trampolines to EL0 zero every register but the argument.
  • SYS_DEBUG actions with no AArch64 counterpart — the double fault, the TLB acknowledgement delay — answer NotSupported instead of panicking the kernel.
  • Selftests (boot actuators): irq-storm floods this CPU with SGIs, each sent once the last is taken (a pending SGI sent again merges with it on hardware), until the 1 ms timer has fired a thousand times through the flood. timer-floor makes the timer due with interrupts masked, calls arm_within(QUANTUM_NS) — which now returns the counter value it set CNTV_CVAL_EL0 from — and reads the comparator back: it must be at least floor_ticks() past that same value. A counter read framed around the call, PR No QEMU test measures time, and audio is judged on metal only #562's original shape, is not a value relation: under TCG the call alone can run tens of thousands of ticks, past the floor regardless of the clamp, which is exactly how round 4 (below) found it.
  • TLB invalidation (tlb.rs): the page-table edit that clears an entry is the one place its translation is dropped — AddressSpace::unmap and replace broadcast TLBI VAE1IS/ASIDE1IS + DSB ISH. arch::tlb::shootdown, which Unmapped, the pipe window revoke and dlopen call after such an edit, does nothing on AArch64; the ASID pool's reclaim calls tlb::all (VMALLE1IS) directly.

Shared code: one declaration each

  • kernel/src/hw.rs is new and portable: KernelHw, its Kicker and Machine impls (need_resched, idle_wait, DIAG_TICK_NS), RUNNING_CTX, report_contexts (x86's, which said more) and MIN_ONE_SHOT. Each architecture's hw.rs keeps its halt and its Hw::switch.
  • KernelCtx and the spawn paths name the saved stack pointer and the thread pointer by role; issues/kernel/the-saved-kernel-context-names-x86-registers.md is met and deleted.
  • The exit-to-user epilogue is scheduler::exit_to_user; region bookkeeping is vma::Regions; the idle-stack arena is sched::idle_stack.
  • Names ARM code reads are portable: clock::counter_ticks (was tsc_ticks), percpu::kernel_timer_fires and kin (were ring0_…), scheduler::leave_user_if_due (was leave_ring3_if_due), and exit_to_user's comments speak of user mode and interrupts. The rest of the portable TSC vocabulary is filed.
  • The crash dump says again whether TP+0 holds variant II's self-pointer, where the TLS variant is II (x86).

Stubs, owned by the small-kernel track (one line each in the track): arch::msi_message refuses on AArch64, so the kernel's xHCI, NVMe, HDA, virtio-sound, virtio-console and virtio-gpu drivers each refuse their function by name; drivers::gop refuses a scanout that is not whole 2 MiB pages of its own. The GICv3 ITS moves to stage 6.

Owed by name, no stage yet: the pseudo-NMI, the SBSA watchdog, the wall clock.

The guest tests

All under Profile::VirtEl2 (TCG -cpu max, EL2) but virt_early_panic and virt_early_fault (Profile::Virt, HVF).

  • virt_user_mode: init reaches spawn: /system/bin/logd, which needs a demand-paged EL0 and a syscall.
  • virt_irq_storm, virt_timer_floor: the selftests above, judged by their PASS line through virt_selftest.
  • tests/virtjobcase (was virtpreemptcase) runs five toybox applets and, last, test_rs_abuse_readonly_copyout on one CPU, with a job bound of four minutes; each has its own test through virt_job, which boots the case with TEST_KERNEL and needs the job's line and exit=0. virt_job waits with await_marker, so a kernel that panics ends the wait once the guest goes quiet, with the panic report in the verdict (the x86 harness's serial::died reading), rather than after the whole drain ceiling:
    • virt_timer_preempts — preempt: a counting thread that never enters the kernel is preempted twice.
    • virt_fp_isolation — fp_isolation: v0–v31, FPCR and FPSR pinned and held while a sibling that loads another state is seen to run three times; the state must read back whole.
    • virt_first_entry — first_entry: a raw thread whose first instruction stores x1–x30; every one must be zero.
    • virt_unmap_touch — unmap_touch: four children each write a page, print, then read it, munmap it and read it again in one assembly block (arch::read_unmap_read); each must end with exit status -1, the status kill_process(-1) gives a fault, so a refused munmap or a panic is red.
    • virt_debug_refused — debug_refused: SYS_DEBUG's double fault and TLB acknowledgement delay answer NotSupported.
    • virt_readonly_copyout — test_rs_abuse_readonly_copyout, below: a syscall writes into user memory only where EL0 could store, which on AArch64 is AddressSpace::leaf's Write check.
  • The applets' assembly lives in userland/toybox/src/arch/, placed in the source gate as metalprobe's is; x86-64's module carries its own read_unmap_read and names where its FP and first-entry probes are (test_rs_fpu_isolation, and the open x86 first-entry issue).
  • test_rs_abuse_readonly_copyout is portable (round 7, the review's blocker: no AArch64 test could fail on leaf()'s Write check). It issued its calls with the x86 syscall instruction; it now goes through toyos_abi::syscall's typed wrappers, naming the target with a slice over the read-only address as tls_dtv_race does, and its assembly exemption in src/sourcegate.rs is deleted. Its four arms — a read-only anonymous mmap, its own .text, the straddle (a read from the last writable page past .bss into the read-only one after it) and the clock page every process maps — each ask through both copies a syscall writes with: read's bulk window, and a typed copy_out. That typed call was fstat, whose wrapper returns its Stat rather than taking an address; it is now process_stats of a child the test spawns first, the same copy_out. Every arm runs, and the exit status carries one bit per arm that let a write through (1 mmap, 2 text, 4 straddle, 8 clock), which the kernel's own exit: record prints even once a rewritten clock page has taken logd down; the straddle's search reports rather than panics when no page refuses, so a kernel that grants every write still reaches the clock arm. On x86-64 it stays a shared-boot test (abuse_readonly_copyout, Fast), proving what it proved, declared in DRIVEN_AND_SHARED. build::build_toyos_bin builds one binary of a test crate for one architecture (the crate's other binaries do not all build for AArch64); virt_job builds it once per process and puts it on ROOT for every boot of the case, since every job runs in every boot.

Guest runs (the orchestrator's)

At 27f08d4 (round 3), the orchestrator measured:

  • every virt_ test EXIT=0, and the Fast tier EXIT=0;
  • red (EXIT=1): b1, b3, b4, n3, m5, m6, m7, b6;
  • b5 green — the blocker round 4 fixes: the unmap's TLBI was not the only invalidation.

At 4256e5a (round 4), everything came back as wanted except b1 and b7, both green: floor_selftest's verdict compared CVAL to a counter read taken before arm_within, which the mutation never touches, so a slow call under TCG load hid both a missing clamp (b1) and a floored-but-unprogrammed one (b7). At 0751537 the verdict is the value relation described above; b1 and b7 are expected red by construction — b1 leaves CVAL − now = 1 past whatever now the arm used, b7 leaves the hardware CVAL programmed off the raw, unfloored ask — both under floor_ticks(), which QEMU's own count checks regardless of how long anything took. Changing arm_ticks's return also forced rearm's match arm to discard it, which m7's patch is regenerated against. At 0751537 the orchestrator measured the whole guest set green and every mutation red. b260578 merged origin/main (7e15181: #583, #586, #597, #600, #555). b0db9fb merges origin/main (d1d83f6: #611, #593, #610); its one conflict was aarch64/paging.rs, where #593 had renamed the uninhabited stub's translate_writable to leaf, and this branch's real AddressSpace now implements leaf (above).

At b0db9fb (round 6), cargo test --test toyos-build -- <filter> in this worktree, clean but for the patch named (the Fast tier is cargo test); logs are 589r6-<run>.log in the orchestrator's job scratchpad:

run patch EXIT note
virt_ — 0 11 passed
Fast tier — 0 241 passed
virt_timer_floor b1-no-floor 1
virt_fp_isolation b3-no-fp-restore 1
virt_first_entry b4-no-zeroing 1
virt_unmap_touch b5-no-unmap-tlbi 1
virt_ b6-kernel-at-merge-base 1 8 failed; virt_early_panic, virt_early_fault, virt_el2_drop (stage 3) passed
virt_timer_floor b7-floor-stored-not-programmed 1
virt_timer_preempts m5-el0-tick-no-preempt 1
virt_user_mode m6-no-demand-fill 1
virt_irq_storm m7-tick-never-rearmed 1
virt_debug_refused n3-debug-panics 1
virt_debug_refused n4-double-fault-panics 1

5792355 merges origin/main (8a66b44: #587, #603, #606, #608, #612, #615), whose kernel changes (clock.rs, vfs.rs, fat32_adapter.rs, object/ops.rs, two selftests) touch no AArch64 file; at 5792355 the orchestrator measured: all twelve virt_ tests EXIT=0; x86-64 abuse_readonly_copyout EXIT=0; the Fast tier EXIT=0; W1 EXIT=1 with the job's kernel exit: record at code=15 (every arm); W2 EXIT=1 with TEST_END exit=4 and "read wrote across a writable page into the read-only one".

Negative controls and the oracle

High-risk: memory management, the context switch, interrupt entry, the isolation of a new thread.

Whole-change negative control (B6). kernel/ reverted to the merge-base with origin/main (d1d83f6 at b0db9fb), keeping this branch's tests, userland/toybox, toyos-bootmap and toyos-gicv3: git diff --binary HEAD $(git merge-base HEAD origin/main) -- kernel/. At b0db9fb it applied, the AArch64 image builds (cargo run -- --build-only --arch aarch64) and the AArch64 test kernel checks, EXIT=0 each; tree clean after; it was not regenerated at 5792355. Expected: every stage-4 virt_ test red, since the kernel at the merge-base stops at stage 3.

Single mutations. Each is a checked patch (below), regenerated at b0db9fb and applying unchanged at 5792355. mutate.sh applied each, built the mutated AArch64 image with cargo run -- --build-only --arch aarch64, checked the test kernel with cargo check --target aarch64-unknown-none-softfloat --features boot-actuators,test-actuators in kernel/ (the feature set n3 and n4 live under), EXIT=0 each at 5792355, and reversed it in the same script (git diff --quiet clean after each). Expected reds:

  • b1 (the floor is one counter tick): virt_timer_floor.
  • b3 (the FP/SIMD restore deleted): virt_fp_isolation.
  • b4 (zero_registers!() deleted, with its now-unused macro, which -D unused-macros otherwise refuses): virt_first_entry.
  • b5 (the unmap's TLBI deleted, with the asid it read — now the only invalidation an unmap gets): virt_unmap_touch.
  • b7 (the review's: arm_ticks stores the floored span and programs the raw one): virt_timer_floor.
  • n3 (the TLB acknowledgement delay panics again): virt_debug_refused.
  • n4 (the double fault panics instead of refusing): virt_debug_refused.
  • m5 (the EL0 tick does not preempt): virt_timer_preempts; virt_fp_isolation too, whose sibling runs only when the tick preempts.
  • m6 (no demand fill): virt_user_mode and every job test.
  • m7 (the tick is never re-armed): virt_irq_storm.
  • W1 (the review's: leaf() grants every Write; the walk's leaf binding becomes _leaf, without which -D unused-variables refuses the mutant, measured build EXIT=101): virt_readonly_copyout, with the job's exit: record at code=15 — every arm.
  • W2 (the review's: leaf() answers the leaf's whole size, not the bytes to its end): virt_readonly_copyout, code=4 — the straddle arm. W2 overstates every copy that crosses a leaf's end, so an earlier job may die first: red on the target, without the per-arm evidence.
  • T (the review's: copy_out asks for Access::Read): virt_readonly_copyout and abuse_readonly_copyout both red expected. The patch built for x86-64 and AArch64, EXIT=0 each, reversed, tree clean.
  • X1 (the review's: x86-64 leaf() grants every Write; rights becomes _rights and STORE goes, without which -D warnings refuses it, measured EXIT=101): abuse_readonly_copyout red expected. Built EXIT=0, reversed, tree clean.

Host mutations run here. Deleting direct_map_end's start-off-page refusal reds a_start_off_a_page_is_refused (EXIT=101); deleting its end-below-start refusal reds an_end_below_its_start_is_refused (EXIT=101); restored after each.

Independent oracle. The Arm ARM K.a (VMSAv8-64, the generic timer, exceptions) and the GIC architecture specification IHI 0069H, exercised by QEMU's TCG model, a third-party implementation of both. It covers the GIC, timer, exception, FP-switch and first-entry claims; it cannot fail on the instruction-cache, break-before-make and ASID-reclaim claims, which the track records as owed to the first HVF run.

Gates (at 20ded4d)

  • cargo run -- --ci host: EXIT=0 (54 steps green); build_toyos_bin shares its setup with build_toyos_bins (TestBuild)
  • cargo run -- --build-only (x86-64): EXIT=0
  • cargo run -- --build-only --arch aarch64: EXIT=0
  • cargo test --test toyos-build -- --list: EXIT=0 (lists virt_readonly_copyout and abuse_readonly_copyout)
  • W1, W2 and the ten single mutations applied at 5792355, the mutated AArch64 image built and the test kernel checked: EXIT=0 each; tree clean after each. The portable test binary built for aarch64-unknown-toyos and x86_64-unknown-toyos with cargo build --bin abuse_readonly_copyout: EXIT=0 each. Guest runs are the orchestrator's.

What I'm unsure of

  • A &mut over read-only memory is filed: issues/design-debt/the-readonly-copyout-test-forms-mut-over-pages-nothing-may-write.md.
  • b5's red needs one of the four children to keep its translation across its own munmap; a switch at that svc's return writes TTBR0_EL1 with another ASID, which drops QEMU's TLB and makes that child fault anyway.
  • HVF readbacks (ID_AA64MMFR0_EL1.ASIDBits, ICC_SRE_EL1 under HVF on the M4) stay unmeasured until stage 6 gives HVF its RNDR.

Filed, not fixed

  • issues/isolation/aarch64-el1-runs-without-pan.md (N1)
  • issues/kernel/an-aarch64-crash-report-reads-through-any-user-leaf.md (N4; round 7 adds user_ptr's direct-map copies and dump_crash_diagnostics)
  • issues/kernel/portable-kernel-code-names-the-tsc.md (N2's remainder)
  • issues/isolation/a-new-x86-thread-enters-ring-3-holding-kernel-register-values.md
  • issues/kernel/the-x86-address-space-keeps-a-page-map-nothing-fills.md
  • issues/design-debt/the-x86-toybox-ships-two-applets-that-only-panic.md
The patches, applying at b0db9fb and unchanged at 5792355, and W1 and W2 at 5792355 (b6 is the command above)
# b1-no-floor
diff --git a/kernel/src/arch/aarch64/irqchip.rs b/kernel/src/arch/aarch64/irqchip.rs
index 676f0e80..bef603e2 100644
--- a/kernel/src/arch/aarch64/irqchip.rs
+++ b/kernel/src/arch/aarch64/irqchip.rs
@@ -305,7 +305,7 @@ fn floor_ticks() -> u64 {
 /// EL1 fire re-arms with. Returns the counter value the comparator was set
 /// from, for a caller that must relate CVAL back to it without a second read.
 fn arm_ticks(ticks: u64) -> u64 {
-    let ticks = ticks.max(floor_ticks());
+    let ticks = ticks.max(1);
     percpu::set_armed_ticks(ticks);
     let now = cpu::counter();
     // SAFETY: the EL1 virtual timer's comparator and control; CPACR has
# b3-no-fp-restore
diff --git a/kernel/src/arch/aarch64/switch.rs b/kernel/src/arch/aarch64/switch.rs
index c7f98cac..6c49361f 100644
--- a/kernel/src/arch/aarch64/switch.rs
+++ b/kernel/src/arch/aarch64/switch.rs
@@ -67,26 +67,6 @@ pub(crate) unsafe extern "C" fn context_switch(old_sp: *mut u64, new_sp: u64) {
         "str x9, [x0]",
         "mov sp, x1",
         "add x9, sp, #{fp}",
-        "ldp q0, q1, [x9, #0]",
-        "ldp q2, q3, [x9, #32]",
-        "ldp q4, q5, [x9, #64]",
-        "ldp q6, q7, [x9, #96]",
-        "ldp q8, q9, [x9, #128]",
-        "ldp q10, q11, [x9, #160]",
-        "ldp q12, q13, [x9, #192]",
-        "ldp q14, q15, [x9, #224]",
-        "ldp q16, q17, [x9, #256]",
-        "ldp q18, q19, [x9, #288]",
-        "ldp q20, q21, [x9, #320]",
-        "ldp q22, q23, [x9, #352]",
-        "ldp q24, q25, [x9, #384]",
-        "ldp q26, q27, [x9, #416]",
-        "ldp q28, q29, [x9, #448]",
-        "ldp q30, q31, [x9, #480]",
-        "ldr x10, [x9, #512]",
-        "msr fpcr, x10",
-        "ldr x10, [x9, #520]",
-        "msr fpsr, x10",
         "ldp x19, x20, [sp, #0]",
         "ldp x21, x22, [sp, #16]",
         "ldp x23, x24, [sp, #32]",
# b4-no-zeroing
diff --git a/kernel/src/arch/aarch64/entry.rs b/kernel/src/arch/aarch64/entry.rs
index 31a0cdf0..a04ba054 100644
--- a/kernel/src/arch/aarch64/entry.rs
+++ b/kernel/src/arch/aarch64/entry.rs
@@ -12,20 +12,6 @@ use super::switch::{DAIF_AT, FRAME_BYTES, RETURN_AT};
 /// EL0 is, for `trampoline_entry`'s contract.
 const DAIF_MASKED: u64 = 0b1111 << 6;
 
-/// Zero x1–x30, the registers a first entry to EL0 must not carry.
-macro_rules! zero_registers {
-    () => {
-        concat!(
-            "mov x1, xzr\n", "mov x2, xzr\n", "mov x3, xzr\n", "mov x4, xzr\n", "mov x5, xzr\n",
-            "mov x6, xzr\n", "mov x7, xzr\n", "mov x8, xzr\n", "mov x9, xzr\n", "mov x10, xzr\n",
-            "mov x11, xzr\n", "mov x12, xzr\n", "mov x13, xzr\n", "mov x14, xzr\n", "mov x15, xzr\n",
-            "mov x16, xzr\n", "mov x17, xzr\n", "mov x18, xzr\n", "mov x19, xzr\n", "mov x20, xzr\n",
-            "mov x21, xzr\n", "mov x22, xzr\n", "mov x23, xzr\n", "mov x24, xzr\n", "mov x25, xzr\n",
-            "mov x26, xzr\n", "mov x27, xzr\n", "mov x28, xzr\n", "mov x29, xzr\n", "mov x30, xzr\n",
-        )
-    };
-}
-
 /// A thread's first entry to EL0 at `x19` on the stack `x20`, with `x0` =
 /// `x21`: a process's argument is zero, a thread's is its own. `SPSR_EL1`
 /// zero is EL0 with every exception unmasked, and `SP_EL1` is left at the
@@ -38,7 +24,6 @@ pub(crate) extern "C" fn process_start() {
         "msr sp_el0, x20",
         "msr spsr_el1, xzr",
         "mov x0, x21",
-        zero_registers!(),
         "eret",
         unlock = sym crate::sched::driver::trampoline_entry,
     );
# b5-no-unmap-tlbi
diff --git a/kernel/src/arch/aarch64/paging.rs b/kernel/src/arch/aarch64/paging.rs
index df990ec4..b6d47d18 100644
--- a/kernel/src/arch/aarch64/paging.rs
+++ b/kernel/src/arch/aarch64/paging.rs
@@ -444,7 +444,6 @@ impl AddressSpace {
     pub fn unmap(&mut self, vaddr: UserAddr) {
         let va = vaddr.raw();
         assert!(va & (PAGE_2M - 1) == 0, "unmap: vaddr {va:#x} not 2MB-aligned");
-        let asid = self.asid.value();
         let Some(directory) = self.tables.find_directory(va) else { return };
         let i = index(va, 2);
         let entry = directory.0[i];
@@ -457,11 +456,6 @@ impl AddressSpace {
             None => entry & ADDR_2M,
         };
         self.tables.directory(va).set(i, 0);
-        if entry & TABLE != 0 {
-            tlb::asid(asid);
-        } else {
-            tlb::page(asid, va);
-        }
         crate::sched::futex::revoke_range(phys, PAGE_2M);
     }
 
# b7-floor-stored-not-programmed
diff --git a/kernel/src/arch/aarch64/irqchip.rs b/kernel/src/arch/aarch64/irqchip.rs
index 676f0e80..6da46593 100644
--- a/kernel/src/arch/aarch64/irqchip.rs
+++ b/kernel/src/arch/aarch64/irqchip.rs
@@ -305,8 +305,7 @@ fn floor_ticks() -> u64 {
 /// EL1 fire re-arms with. Returns the counter value the comparator was set
 /// from, for a caller that must relate CVAL back to it without a second read.
 fn arm_ticks(ticks: u64) -> u64 {
-    let ticks = ticks.max(floor_ticks());
-    percpu::set_armed_ticks(ticks);
+    percpu::set_armed_ticks(ticks.max(floor_ticks()));
     let now = cpu::counter();
     // SAFETY: the EL1 virtual timer's comparator and control; CPACR has
     // nothing to say about them and `CNTKCTL_EL1` keeps EL0 out.
# n3-debug-panics
diff --git a/kernel/src/arch/aarch64/tlb.rs b/kernel/src/arch/aarch64/tlb.rs
index d46f19b0..0cfe8633 100644
--- a/kernel/src/arch/aarch64/tlb.rs
+++ b/kernel/src/arch/aarch64/tlb.rs
@@ -99,11 +99,11 @@ pub fn bench() {
 /// x86-64's delays an acknowledgement, and there is none here: refused.
 #[cfg(feature = "test-actuators")]
 pub fn debug_arm_ack_delay(_nanos: u64) -> u64 {
-    toyos_abi::syscall::SyscallError::NotSupported.to_u64()
+    panic!("SYS_DEBUG: AArch64 invalidates by broadcast, so there is no acknowledgement to delay")
 }
 
 /// x86-64's delays an acknowledgement, and there is none here: refused.
 #[cfg(feature = "test-actuators")]
 pub fn debug_disarm_ack_delay() -> u64 {
-    toyos_abi::syscall::SyscallError::NotSupported.to_u64()
+    panic!("SYS_DEBUG: AArch64 invalidates by broadcast, so there is no acknowledgement to delay")
 }
# n4-double-fault-panics
diff --git a/kernel/src/arch/aarch64/trap.rs b/kernel/src/arch/aarch64/trap.rs
index a8c433f5..2b4a0615 100644
--- a/kernel/src/arch/aarch64/trap.rs
+++ b/kernel/src/arch/aarch64/trap.rs
@@ -495,7 +495,7 @@ pub(crate) fn report_fault_stack() {}
 /// refused.
 #[cfg(feature = "test-actuators")]
 pub(crate) fn provoke_double_fault() -> u64 {
-    toyos_abi::syscall::SyscallError::NotSupported.to_u64()
+    panic!("SYS_DEBUG: AArch64 has no double fault to provoke")
 }
 
 /// `irq-storm`: this CPU floods itself with SGIs, sending each as soon as the
# m5-el0-tick-no-preempt
diff --git a/kernel/src/arch/aarch64/trap.rs b/kernel/src/arch/aarch64/trap.rs
index a8c433f5..64efbbb7 100644
--- a/kernel/src/arch/aarch64/trap.rs
+++ b/kernel/src/arch/aarch64/trap.rs
@@ -154,7 +154,6 @@ fn irq(from_el0: bool) {
             let hw = &crate::hw::HW;
             hw.trace(TraceEvent { ts: hw.now(), cpu: CpuId(percpu::cpu_id()), kind: TraceKind::TimerFire });
             irqchip::end(intid);
-            crate::scheduler::do_preempt();
         } else {
             crate::preempt::set_need_resched();
             percpu::note_kernel_timer_fire();
# m6-no-demand-fill
diff --git a/kernel/src/arch/aarch64/trap.rs b/kernel/src/arch/aarch64/trap.rs
index a8c433f5..b24425e7 100644
--- a/kernel/src/arch/aarch64/trap.rs
+++ b/kernel/src/arch/aarch64/trap.rs
@@ -250,7 +250,7 @@ fn user_abort(frame: &mut Frame) {
             user_fatal(frame);
         }
         cpu::enable_interrupts();
-        let served = crate::process::handle_page_fault(frame.far, frame.esr);
+        let served = false;
         cpu::disable_interrupts();
         if served {
             percpu::set_fault_state(CpuFaultState::Normal);
# m7-tick-never-rearmed
diff --git a/kernel/src/arch/aarch64/irqchip.rs b/kernel/src/arch/aarch64/irqchip.rs
index 676f0e80..c5a9167c 100644
--- a/kernel/src/arch/aarch64/irqchip.rs
+++ b/kernel/src/arch/aarch64/irqchip.rs
@@ -361,12 +361,7 @@ pub fn stop_timer() {
 /// A timer interrupt taken: armed again for what it was last armed for, or
 /// stopped if it was stopped — the one thing that deasserts it.
 pub(super) fn rearm() {
-    match percpu::armed_ticks() {
-        0 => stop_timer_hardware(),
-        ticks => {
-            arm_ticks(ticks);
-        }
-    }
+    stop_timer_hardware();
 }
 
 /// `timer-floor`: this CPU's timer made due with interrupts masked, then
# w1-write-always-granted
diff --git a/kernel/src/arch/aarch64/paging.rs b/kernel/src/arch/aarch64/paging.rs
index df990ec4c..678319991 100644
--- a/kernel/src/arch/aarch64/paging.rs
+++ b/kernel/src/arch/aarch64/paging.rs
@@ -477,10 +477,10 @@ impl AddressSpace {
     /// memory goes through this, so a syscall cannot write a page the process
     /// itself may not — the clock page, a shared library's `.text`.
     pub fn leaf(&self, vaddr: UserAddr, access: toyos_userbound::Access) -> Option<(u64, u64)> {
-        let (at, leaf, size) = self.walk(vaddr)?;
+        let (at, _leaf, size) = self.walk(vaddr)?;
         let granted = match access {
             toyos_userbound::Access::Read => true,
-            toyos_userbound::Access::Write => leaf & (AP_EL0 | AP_READ_ONLY) == AP_EL0,
+            toyos_userbound::Access::Write => true,
         };
         granted.then(|| (at.phys(), size - (vaddr.raw() & (size - 1))))
     }
# w2-leaf-length-overstated
diff --git a/kernel/src/arch/aarch64/paging.rs b/kernel/src/arch/aarch64/paging.rs
index df990ec4c..c0818dba2 100644
--- a/kernel/src/arch/aarch64/paging.rs
+++ b/kernel/src/arch/aarch64/paging.rs
@@ -482,7 +482,7 @@ impl AddressSpace {
             toyos_userbound::Access::Read => true,
             toyos_userbound::Access::Write => leaf & (AP_EL0 | AP_READ_ONLY) == AP_EL0,
         };
-        granted.then(|| (at.phys(), size - (vaddr.raw() & (size - 1))))
+        granted.then(|| (at.phys(), size))
     }
 
     /// The direct-map address of `vaddr`, the leaf descriptor that maps it, and the size that leaf maps.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs

Japabu and others added 2 commits September 28, 2026 21:42
…e timer, and user mode

The port's stage 4 (issues/kernel/toyos-runs-on-arm64.md), ahead of
small-kernel stage 6 on the owner's word, with the drivers that track moves
out of the kernel refused at the narrowest seam rather than ported.

AArch64:
- paging: TTBR1_EL1 holds the kernel's direct map of every 4 KiB page
  firmware's map calls memory, and nothing else; each user space is a
  TTBR0_EL1 root under a 16-bit ASID from toyos-pcid. Live entries are
  replaced break-before-make, a page is never retyped, and executable
  pages are made coherent with the instruction stream before they map.
- control registers: CPACR lets EL1 and EL0 use FP/SIMD, CNTKCTL gives
  EL0 the virtual count, TCR asks for 16-bit ASIDs (checked), ICC_SRE puts
  the GICv3 CPU interface in system registers (ICC_SRE_EL2 first at EL2),
  and a CPU without one halts in a named refusal.
- per-CPU state through TPIDR_EL1; the GICv3 distributor and this CPU's
  redistributor, SGIs and the virtual timer's PPI; the vectors dispatch an
  SVC to the syscall dispatcher, a translation fault to the demand pager,
  a user fault to the end of its process, and an interrupt to the tick or
  kick that preempts at EL0; the context switch carries FP/SIMD, since the
  soft-float kernel never touches it; trampolines to EL0 zero every
  register but the argument.
- irq-storm: a boot actuator that ticks the timer under an SGI flood and
  says whether any tick was lost.

Shared:
- KernelCtx and the spawn paths name the stack pointer and thread pointer
  by role, which closes issues/kernel/the-saved-kernel-context-names-x86-registers.md.
- The exit-to-user epilogue moves from x86's idt into scheduler.rs; the
  region bookkeeping from x86's AddressSpace into vma::Regions; the idle
  stack arena from x86's percpu into sched::idle_stack.
- arch::msi_message may refuse, and on AArch64 does: the GICv3 ITS moves
  to stage 6, where a claimed function behind the SMMUv3 is its only
  consumer the small-kernel track leaves.
- gop::init refuses a scanout that is not whole 2 MiB pages of its own.
- paging::init is handed the scanout, which AArch64 maps before its switch.

Tests (Tier::Local, VirtEl2): virt_user_mode, virt_timer_preempts (a new
tests/virtpreemptcase), virt_irq_storm.

Filed: issues/isolation/a-new-x86-thread-enters-ring-3-holding-kernel-register-values.md,
issues/kernel/the-x86-address-space-keeps-a-page-map-nothing-fills.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6rME2DoqwjcYFStYHHY4j
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6rME2DoqwjcYFStYHHY4j
@Japabu
Japabu marked this pull request as ready for review September 28, 2026 19:52
…events, not time

The orchestrator's guest run at 57382f7 had four reds.

virt_early_panic and virt_early_fault (HVF, entered at EL1) went silent
after the loader's handoff, where main is green: this branch put two GIC
instructions ahead of the first record on the EL1 path, the
ID_AA64PFR0_EL1.GIC gate that halts silently in refused_no_gicv3 and an
ICC_SRE_EL1 write under firmware's vectors. QEMU 11.1.1's HVF sets that
register's GIC field from env->gicv3state at vCPU creation
(target/arm/hvf/hvf.c:1481-1483), which the GIC's realize fills in only
after virt.c has realized every CPU (hw/arm/virt.c:3140 before 3158), so
under HVF the field is whatever Hypervisor.framework reports for an Apple
core; which of the two ended the boot is not measured. Both leave the
entry: ICC_SRE_EL1 is written and its SRE read back in irqchip::init, under
this kernel's vectors, where a CPU interface that is not there is an
undefined instruction the kernel reports. The EL2 path still writes
ICC_SRE_EL2, skipped where the ID register names no interface, so that
case too fails under this kernel's vectors rather than firmware's.

virt_irq_storm and virt_timer_preempts judged rates under TCG: ticks per
2000 ms and a tick's lateness, and a job's CPU time against a 45 s bound.
No QEMU test measures time. The storm now floods SGIs until the timer has
fired a thousand times and then waits for every SGI it sent, so a tick lost
or never re-armed, or an SGI lost, leaves it unsaid; irqchip::lateness
goes with the lateness verdict. virt_timer_preempts runs toybox's new
`preempt`: a thread counting with no syscall, and a thread that yields
until it has seen the count move twice, which on one CPU happens only when
an interrupt took the CPU from the counter. The old test's red was its own
ordering: it drained to spin's exit line, and the runner's line reaches
the console after it.

The track records that no QEMU test can show the EL2 deletions red: QEMU
resets CNTHCTL_EL2, CNTVOFF_EL2 and CPTR_EL2 to values the declaration
agrees with, and ICC_SRE_EL2 is a constant; each deletion stayed green in
virt_user_mode at 57382f7. The interrupts-off window is metal's to
measure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #589 at 9b7ed0c

No earlier review is posted on this PR, so this round covers the whole branch. The gate holds. CI host concluded SUCCESS at 9b7ed0c. The orchestrator's 589r2 runs at 9b7ed0c give virt_ EXIT=0 and Fast EXIT=0. The mutation runs are m5 (virt_timer_preempts red), m6 (virt_user_mode and virt_timer_preempts red) and m7 (virt_irq_storm red). None of these results is in the PR body.

Growth: production +2836 −585 (net +2251), tests +194, issues +69 −24. The deletion owed is the set of siblings in B2.

m5, m6 and m7 each revert what they claim. m7 going red only on virt_irq_storm is the right signal: an EL0 tick reaches do_preempt, which runs set_timer, so only a CPU held at EL1 with interrupts open depends on rearm. The named oracle is QEMU TCG as a third-party implementation of the Arm ARM and IHI 0069H. It covers the GIC, timer and exception claims. It does not cover the I-cache and break-before-make claims (N6). Citations are not checked, per the review prompt.

BLOCKER

  • B1 kernel/src/arch/aarch64/irqchip.rs:352 — arm_ticks(want.min(remaining).max(1)) can arm for as little as one counter tick. It also stores that span in armed_ticks, and rearm repeats it on every EL1 fire. x86's OneShot::ticks clamps the same value to MIN_ONE_SHOT (x86_64/apic.rs:241-243). The result is an EL1 timer that re-fires every ~16 ns, so a page fault or kernel thread with interrupts open makes no progress. Fix: clamp to the floor, as x86 does.
  • B2 kernel/src/arch/aarch64/hw.rs:54,69,84,87,94 and irqchip.rs:220,302 — these are forks of existing code, although the PR says "moved rather than forked". need_resched, idle_wait, DIAG_TICK_NS, RUNNING_CTX, report_contexts and MIN_ONE_SHOT copy x86_64/hw.rs and apic.rs line for line, and none of them contains anything specific to either architecture. packed_affinity repeats cpu::hardware_id's packing. Each needs one portable declaration.
  • B3 kernel/src/arch/aarch64/switch.rs:70-89 — no test can fail on the FP/SIMD save. Patch: delete the 16 ldp qN, qN+1 lines and the ldr x10/msr fpcr/msr fpsr restore. Every virt_ test should stay green under it. The track's stage 4 names "This stage's timer and FP tests". An fpu_isolation-shaped test under Profile::VirtEl2 must turn red under this patch.
  • B4 kernel/src/arch/aarch64/entry.rs:41 — no test can fail on the security claim that a first ERET to EL0 carries no kernel register. Patch: delete zero_registers!(),. A VirtEl2 guest test that reads x1–x30 at the first EL0 instruction must turn red. This is the exit the branch's own x86 isolation issue names.
  • B5 kernel/src/arch/aarch64/paging.rs:460-464 — I suspect this mutation leaves every virt_ test green. Patch: delete the if entry & TABLE != 0 { tlb::asid(asid) } else { tlb::page(asid, va) } block in unmap. QEMU's softmmu TLB keeps the stale entry, so a VirtEl2 test that touches a page, unmaps it and touches it again must end its process under the unmutated kernel and must turn red under the patch.
  • B6 PR body — the PR names no whole-change negative control; m1–m7 are single mutations. Needed: kernel/ reverted onto e3a1cdc with tests/, userland/toybox and toyos-bootmap kept, and virt_user_mode, virt_timer_preempts and virt_irq_storm measured red there.

NOTE

  • N1 kernel/src/arch/aarch64/control_regs.rs:24 — PAN is never set (SPAN=1, PSTATE.PAN never written), while x86 runs with SMAP. Set it, or file it.
  • N2 kernel/src/arch/aarch64/irqchip.rs:305, kernel/src/scheduler.rs:420 — ARM code now reads clock::tsc_ticks, ring0_timer_fires and the "Ring 3"/IF vocabulary of exit_to_user. The TSC issue covers only KernelArgs, and no issue covers the Ring 0/3 names.
  • N3 kernel/src/arch/aarch64/tlb.rs:101,107 and trap.rs:512 — a user SYS_DEBUG call panics the kernel instead of being refused.
  • N4 kernel/src/arch/aarch64/paging.rs:776 and trap.rs:348 — the crash report reads a user-chosen x29 through a direct map that holds no registers. Once a BAR reaches EL0 (stage 6), a fault with x29 in that BAR becomes an EL1 abort. File it.
  • N5 origin/main has moved to 8a8fe27, bringing The loader-slimming track, and the firmware rule in CLAUDE.md #582's KernelArgs change and No QEMU test measures time, and audio is judged on metal only #562. No guest run covers the composed aarch64 kernel.
  • N6 kernel/src/arch/aarch64/cache.rs make_executable, the break-before-make ordering and the ASID reclaim flush — no oracle can fail on these under TCG, and the PR body's "What I'm unsure of" is not a record. Record them in the track's stage 4, with the first HVF run as the exit.
  • N7 toyos-bootmap/src/aarch64.rs:105,110 — the start-off-page refusal and the end < start refusal have no host test. Deleting either check leaves the suite green.
  • N8 kernel/src/arch/aarch64/irqchip.rs:225,263 — the ICC_SGI1R encoding (the RS field for Aff0 ≥ 16) and the VLPIS stride are pure decisions exercised only by CPU 0. Move them to a pure crate with host tests.
  • N9 kernel/src/arch/aarch64/trap.rs:468 — Vector::Hda = 2 = irqchip::SGI_STORM: one INTID space with two declarations.
  • N10 kernel/src/arch/aarch64/trap.rs:564 — sent == taken relies on QEMU taking each self-SGI before the next send. On metal, a pending SGI that is sent again merges with it, and the storm hangs.
  • N11 kernel/src/process.rs:1479 — x86 lost its fs:[0] self-pointer check in the crash dump.
  • N12 kernel/src/arch/aarch64/trap.rs:505 — kernel_exit_to_user_check only forwards the call. trampoline_entry can call scheduler::exit_to_user directly, and the aarch64 function can be deleted.
  • N13 PR body — add the 9b7ed0c guest runs: each command, its EXIT code and its log.

REMOVE

  • kernel/src/actuator.rs:351 — "say whether any tick went a whole period untaken" is false; the storm judges no period.
  • kernel/src/arch/aarch64/control_regs.rs:50 — "across every entry from EL0 (super::trap)" is false; the entry saves no FP state, switch.rs does.
  • kernel/src/arch/aarch64/irqchip.rs:302 — "a thousandth of QUANTUM_NS" restates a count that another landing moves.
  • kernel/src/arch/aarch64/mod.rs:6 — the rewritten "What exists and what is owed" paragraph is status that rots at stage 5.
  • issues/kernel/toyos-runs-on-arm64.md:312-317 — "which no QEMU test can do" is false: the same sentence names a loader that writes the opposite values, and that loader runs under QEMU. The QEMU 11.1.1 and 57382f7 provenance also rots; at that head the timer and storm tests were red unmutated.
  • issues/isolation/a-new-x86-thread-enters-ring-3-holding-kernel-register-values.md:17-18 — "is the shape to copy" is not load-bearing.
  • tests/toyos.rs:5966 — the virt_timer_preempts comment restates preempt.rs's module doc.
  • PR body — these parts are stale or false at 9b7ed0c: "The fix round after the guest run at 57382f7", "Guest runs to queue", "None has been run in a guest", "I ran no guest", and the m5/m7 "At 57382f7" narratives.

SEND BACK

Japabu and others added 4 commits September 29, 2026 03:05
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review of #589 at 9b7ed0c, B1, B2, N2, N3, N8, N9, N10, N12:

- B1: `irqchip::arm_within` armed `want.min(remaining).max(1)`, as little
  as one counter tick, and stored it as what `rearm` repeats on every EL1
  fire. The floor is now enforced once, in `arm_ticks`, the only write of
  the comparator, as x86's `OneShot::ticks` enforces it. `timer-floor` is
  the boot actuator that makes the timer due, calls `arm_within`, and takes
  a hundred EL1 fires with interrupts open.
- B2: `KernelHw`, its `Kicker` and `Machine` impls (with `need_resched`,
  `idle_wait` and `DIAG_TICK_NS`), `RUNNING_CTX`, `report_contexts` and
  `MIN_ONE_SHOT` move to the portable `kernel/src/hw.rs`; each
  architecture's `hw.rs` keeps its halt and its `Hw::switch`. The unified
  `report_contexts` is x86's, which said more.
- B2/N8: `toyos-gicv3`, a pure crate with host tests, holds the MPIDR
  affinity packing (`cpu::hardware_id` and the MADT match both use it),
  the `ICC_SGI1R_EL1` encoding with its `RS` field, and the redistributor
  walk with its `VLPIS` stride.
- N2: `clock::tsc_ticks` is `counter_ticks`; the percpu timer-fire
  accessors are `kernel_timer_fires` and kin; `leave_ring3_if_due` is
  `leave_user_if_due`, and `exit_to_user`'s comments speak of user mode and
  interrupts rather than Ring 3 and `IF`.
- N3: `SYS_DEBUG`'s double fault and TLB acknowledgement delay answer
  `NotSupported` on AArch64 instead of panicking the kernel.
- N9: one `irqchip::Intid` enum declares the kick, log-nest and storm SGIs
  and the two MSI identities, in one INTID space.
- N10: the storm sends each SGI once the last is taken, since an SGI sent
  while one is pending merges with it on hardware.
- N12: `trampoline_entry` calls `scheduler::exit_to_user` itself, and the
  AArch64 forwarder is gone.
- REMOVE: the storm actuator's false "whole period untaken" clause, and
  "a thousandth of QUANTUM_NS" from the floor's reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…unmap and the refusals

The review of #589 at 9b7ed0c, B1, B3, B4, B5, N3, N7, N11 and the
REMOVEs:

- `tests/virtpreemptcase` becomes `tests/virtjobcase`, whose test-runner
  runs five toybox applets on one CPU under the EL2 profile, each judged
  by its own `virt_` test through `virt_job`: `preempt`
  (`virt_timer_preempts`), `fp_isolation` (B3), `first_entry` (B4),
  `unmap_touch` (B5) and `debug_refused` (N3). The boots carry
  `TEST_KERNEL`, for `SYS_DEBUG`, and a job bound of four minutes.
- `fp_isolation`: pins v0-v31, FPCR and FPSR and holds them until a
  sibling that loads another state has been seen to run three times,
  which on one CPU means three switches away and back.
- `first_entry`: a raw thread whose first instruction stores x1-x30.
- `unmap_touch`: four children each write a page, unmap it and read it;
  each must die on the read.
- `debug_refused`: `SYS_DEBUG`'s TLB acknowledgement delay answers
  `NotSupported`.
- `virt_timer_floor` (B1) boots the `timer-floor` actuator;
  `virt_selftest` judges it and `virt_irq_storm` alike.
- The assembly lives in `userland/toybox/src/arch/`, placed in the source
  gate as metalprobe's is; x86-64's module names where its own probes are.
- N7: two host tests for `direct_map_end`'s start-off-page and
  end-below-start refusals.
- N11: the crash dump says whether TP+0 holds variant II's self-pointer
  again, where the TLS variant is II.
- Filed: `issues/isolation/aarch64-el1-runs-without-pan.md` (N1),
  `issues/kernel/an-aarch64-crash-report-reads-through-any-user-leaf.md`
  (N4), `issues/kernel/portable-kernel-code-names-the-tsc.md` (N2's
  remainder).
- N6: the track's stage 4 records the instruction-cache, break-before-make
  and ASID-reclaim claims as owed, with the first HVF run as their exit.
- REMOVE: the track's "no QEMU test can do" sentence and its provenance,
  the isolation issue's "shape to copy", `aarch64/mod.rs`'s status
  paragraph, `CPACR`'s "across every entry from EL0", and
  `virt_timer_preempts`' restated comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #589, round 3, at 27f08d4

Gate: MERGEABLE. CI host concluded SUCCESS on 27f08d4 (run 36510308603). The orchestrator's 589r3 runs: every virt_ test EXIT=0, Fast EXIT=0.

Growth: production +3035 −801, tests +680, issues +131 −24.

Earlier blockers

  • B1 CLOSED: b1-no-floor gives virt_timer_floor EXIT=1 (timer-floor: FAIL armed=1 floor=10000). The test is weaker than the fix, see B7.
  • B2 CLOSED: kernel/src/hw.rs and toyos-gicv3 hold the one declaration of each sibling.
  • B3 CLOSED: b3-no-fp-restore gives virt_fp_isolation EXIT=1, with fp_isolation exit=101 in the guest.
  • B4 CLOSED: b4-no-zeroing gives virt_first_entry EXIT=1, with first_entry exit=101. process_start is the only ERET trampoline.
  • B5 OPEN: b5-no-unmap-tlbi leaves virt_unmap_touch EXIT=0. See B5 below. The cause is not a context switch.
  • B6 CLOSED: in 589r3-b6-virt_.log the reverted kernel is built both ways and boots. It stops at EARLY PANIC … the kernel's page tables: owed by stage 4, and eight tests are red on it (3 passed, 8 failed). No test is red for a build failure. virt_timer_floor and virt_irq_storm are red because the harness refuses an actuator the reverted kernel does not declare.
  • N3 (promoted) CLOSED for the TLB half: n3-debug-panics gives virt_debug_refused EXIT=1, with a PANIC at tlb.rs:102. The double-fault half is open as a NOTE.

BLOCKER

  • B5 kernel/src/syscall/vm.rs:197, kernel/src/mm/unmapped.rs:19, kernel/src/arch/aarch64/paging.rs:460-464 — every munmap is invalidated twice, so the b5 mutation removes a redundant invalidation. That is why it stays green, and it would stay green under any oracle: TCG, HVF or metal. AddressSpace::unmap issues TLBI VAE1IS/ASIDE1IS + DSB ISH, which already reaches every CPU. sys_munmap then drops Unmapped, and its Drop calls tlb::shootdown → all() = TLBI VMALLE1IS. That drops every ASID's entries and every global entry as well. This is a second code path.
    • Fix:
      • (1) Keep exactly one invalidation per unmap on AArch64. Either delete the unmap's TLBI and let the portable shootdown own it, or stop Unmapped, Pipe and Dlopen from re-flushing everything on AArch64.
      • (2) b5 then deletes the one that remains.
      • (3) The child must fill the TLB after its last syscall that can switch, not before println!. Use one asm block: ldr from the page, svc munmap, ldr. Waking the parent through the pipe switches the CPU, and a TTBR0 write with another ASID flushes QEMU's softmmu TLB. TCG can see a missing TLBI, because it keeps an entry until a TLBI or an ASID change, so no HVF row is needed.
    • The judge must also turn red under this patch, and today it stays green: sys_munmap → return SyscallError::NotFound.to_u64(); at its top. The child's .expect then exits 101, and unmap_touch.rs:35 accepts any non-success. Require the status kill_process(-1) gives.
  • B7 kernel/src/arch/aarch64/irqchip.rs:393 — timer-floor judges percpu::armed_ticks(), a software copy, not the comparator. The b1 log shows the 100-fire loop cannot fail: at armed=1, all 100 EL1 fires completed. This patch stays green: arm_ticks: -let ticks = ticks.max(floor_ticks()); -percpu::set_armed_ticks(ticks); +percpu::set_armed_ticks(ticks.max(floor_ticks()));. It leaves cntv_cval_el0 = counter + raw ticks, a one-tick first arm. Fix: read the counter before arm_within, read CNTV_CVAL_EL0 back after it, and require that the difference is at least floor_ticks(). With the hardware check in place, delete the fire loop.

NOTE

  • kernel/src/syscall/dispatch.rs:541 — on AArch64 the DOUBLE_FAULT arm logs "provoking a double fault" and returns NotSupported. debug_refused never calls it. Restoring panic! in trap.rs:provoke_double_fault leaves virt_debug_refused green. Add debug(DOUBLE_FAULT) to the applet.
  • tests/toyos.rs:3581 — virt_job waits the full ceiling after a kernel PANIC:. n3 took 633 s for a panic at guest time 2.58 s. The drain should stop on the panic line as well.
  • userland/toybox/src/arch/x86_64.rs — the shipping toybox gains two x86 applets whose only content is a panic!. Leave the applets out of x86's commands! and delete the file.
  • PR body — it carries no guest result at 27f08d4 (N13 from round 2).

REMOVE

  • tests/toyos.rs:5130-5132 and kernel/src/arch/aarch64/irqchip.rs:369-371 — "never gets back to say anything" / "no progress to say anything with" is false: the b1 log completed 100 fires at armed=1.
  • userland/toybox/src/unmap_touch.rs:4-5 — "Several children, so the one read that a switch … would have made fault anyway" gives the wrong cause.
  • PR body — false or stale:
    • the Selftests bullet's "a re-arm below the floor … never gets back to report";
    • "What I'm unsure of" on virt_unmap_touch under b5;
    • the oracle's "covers … the unmap claims";
    • "this body carries no guest result for it until then".

SEND BACK

Japabu and others added 7 commits September 29, 2026 07:36
…off the comparator

B5. Every AArch64 munmap was invalidated twice: `AddressSpace::unmap`
issues `TLBI VAE1IS`/`ASIDE1IS` + `DSB ISH`, which already reaches every
CPU, and then `Unmapped`'s drop called `tlb::shootdown`, a `TLBI
VMALLE1IS` that also dropped every other ASID's and every global entry.
So deleting the unmap's TLBI (b5) left `virt_unmap_touch` green on any
oracle. The page-table edit is the single place now: it is precise,
it covers every caller of `unmap` and `replace`, and it is what the
module header already promised. `arch::tlb::shootdown` does nothing on
AArch64, since every portable caller (`Unmapped`, the pipe window
revoke, dlopen) follows an `AddressSpace` edit that has already
broadcast; the ASID pool's reclaim calls `tlb::all` directly, and the
census counts only that.

`unmap_touch`'s child now reads the page, calls munmap and reads again
in one assembly block after its last print (`arch::read_unmap_read`),
so the only switch that could drop the cached translation is the unmap
itself. The parent requires exit status -1, the status
`kill_process(-1)` gives a fault; a refused munmap or a panic no longer
passes.

B7. `timer-floor` judged `percpu::armed_ticks()`, a software copy, and
its 100-fire loop could not fail: the b1 log shows all 100 fires done at
armed=1. It now reads the counter, calls `arm_within(QUANTUM_NS)` on a
due timer, reads `CNTV_CVAL_EL0` back and requires the comparator to be
at least `floor_ticks()` past that counter reading. The reading tells
floored from unfloored only while the ask took less than the floor, so
a wider window is a FAIL as well. The fire loop is deleted.

NOTEs. `debug_refused` also asks for SYS_DEBUG's double fault. `virt_job`
waits with `await_marker`, whose kernel-death reading (`serial::died`,
`kernel_died_here`, `WaitVerdict`) is the x86 harness's: a panicked
guest ends the wait once it goes quiet, with the panic report in the
verdict, instead of after the whole drain ceiling (633 s for a panic at
2.58 s under n3). The two x86 toybox applets that only panic are filed
as issues/design-debt/the-x86-toybox-ships-two-applets-that-only-panic.md.
The screen-test doc comment `virt_job` had split from `run_screen_test`
goes back to it.

REMOVE. The "never gets back to say anything" comment on
`virt_timer_floor`, the selftest's matching "no progress to say anything
with", and `unmap_touch`'s "Several children" line are deleted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR #562 forbids a time verdict in a QEMU selftest: under TCG load a slow
call must not fail a test that measures a value, not a duration. The
timer-floor selftest's `window < floor` clause was exactly that — it
failed whenever the arm_within call itself ran long, for no defect. The
verdict is now only CVAL >= counter_before + floor_ticks(), which holds
however long the call took.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…framing read

PR #562's rule against a timing verdict in a QEMU selftest made
`floor_selftest` compare CVAL to a counter read taken before the call to
`arm_within`. Under TCG that call itself can run tens of thousands of ticks
long — far past `floor_ticks()` — so `span >= floor` held whether or not the
floor clamp fired: a QEMU host under load hid a missing or bypassed clamp
rather than catching it.

`arm_ticks` (and `arm_within`, which ends in it) now return the counter
value they read to compute CVAL, so the selftest relates CVAL to the exact
`now` the arm used — a value relation, not a second, independent read framed
around however long the call took.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
arm_ticks now returns the counter value it armed the comparator from, for
floor_selftest. rearm's match still had a bare `arm_ticks(ticks)` arm
against `stop_timer_hardware()`'s `()`, which no longer type-checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One conflict, kernel/src/arch/aarch64/boot.rs, both hunks kept: main's
(#583) new `report_counter_origin`, empty on AArch64 because no register
says where the generic timer counts from, which main.rs's
`report_power_on` calls on both architectures; and this branch's
`timer()`, its doc and its body (the EL1 virtual timer, logged, stopped
until the scheduler arms it) in place of main's `owed!` stub.

#583's KernelArgs layout word needs nothing on the AArch64 side: the
loader writes it in the portable bootloader/src/main.rs, the kernel
refuses a foreign one in the portable `kernel_main`, which AArch64's
`_start` reaches, and that `_start` reads its four fields by
`offset_of!`, so the layout moves under it by construction.

Auto-merged, each checked against the branch's own hunk: actuator.rs
(main's layout actuator beside this branch's irq-storm and timer-floor),
main.rs (main's layout refusal and power-on report beside this branch's
`mod hw` and headless GOP), x86_64/boot.rs (main's UTC `clock` and
`report_counter_origin` beside this branch's irq-storm/timer-floor
refusal), aarch64/mod.rs (#586 drops log-shared-reservation's window
from `percpu_fetch_add`; this branch's percpu.rs still calls
`log::nested::reserve_window`, which main keeps), clock.rs,
sched/kthread.rs, src/build.rs, src/sourcegate.rs, tests/toyos.rs.
kernel/src/hw.rs is this branch's alone: main touched neither it nor
either architecture's hw.rs. The rust gitlink takes main's 9c3eea44.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #611 (keystore: each record writer has its own temp name) and
#593 (user copies are scatter-gather).

#593 replaced `AddressSpace::translate_writable` with
`AddressSpace::leaf(vaddr, access) -> Option<(phys, bytes to the leaf's
end)>` and left AArch64's as `match self.never {}`. This branch made the
AArch64 `AddressSpace` real, so the conflict in
kernel/src/arch/aarch64/paging.rs resolves to this branch's body with
`translate_writable` replaced by a real `leaf`: `walk` now also answers the
size of the leaf it found (2 MiB block or 4 KiB page), a `Read` is granted
wherever `walk` finds a user leaf, and a `Write` only where the leaf's
`AP[2:1]` is EL0 read-write, the check `translate_writable` made. No table
descriptor this file writes sets `APTable`, so the leaf's `AP` is the whole
walk's answer, as every level's `USER|WRITE` is on x86-64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs
@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Mutation patches at b0db9fb (merge of origin/main d1d83f6)

Regenerated against the merged tree. mutate.sh (in /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/589/) applies each with git apply --check and git apply, builds with cargo run -- --build-only --arch aarch64, also runs cargo check --target aarch64-unknown-none-softfloat --features boot-actuators,test-actuators in kernel/ (the test kernel's feature set, which n3/n4 need to be compiled at all), reverses the patch and proves the tree clean with git diff --quiet. Every step exited 0 for all eleven. Files: /Users/jan/.claude/jobs/2280e09e/tmp/scratchpad/orch/589/patches/.

patch expected red (EXIT=1)
b1-no-floor virt_timer_floor
b3-no-fp-restore virt_fp_isolation
b4-no-zeroing virt_first_entry
b5-no-unmap-tlbi virt_unmap_touch
b6-kernel-at-merge-base every virt_ test
b7-floor-stored-not-programmed virt_timer_floor
m5-el0-tick-no-preempt virt_timer_preempts (and virt_fp_isolation)
m6-no-demand-fill virt_user_mode (and every job test)
m7-tick-never-rearmed virt_irq_storm
n3-debug-panics virt_debug_refused
n4-double-fault-panics virt_debug_refused

b6 is 219549 bytes, past a comment's limit, so it is the command: git diff --binary b0db9fbc d1d83f64 -- kernel/ (sha256 9d68c01eced3b3231365084587ce5d99087845b1a12480a91cd0def55e3b1948, the saved file's hash too). m7's patch in the PR body had lost its trailing context lines and no longer parsed; it is regenerated from the same edit.

# b1-no-floor
diff --git a/kernel/src/arch/aarch64/irqchip.rs b/kernel/src/arch/aarch64/irqchip.rs
index 676f0e80..bef603e2 100644
--- a/kernel/src/arch/aarch64/irqchip.rs
+++ b/kernel/src/arch/aarch64/irqchip.rs
@@ -305,7 +305,7 @@ fn floor_ticks() -> u64 {
 /// EL1 fire re-arms with. Returns the counter value the comparator was set
 /// from, for a caller that must relate CVAL back to it without a second read.
 fn arm_ticks(ticks: u64) -> u64 {
-    let ticks = ticks.max(floor_ticks());
+    let ticks = ticks.max(1);
     percpu::set_armed_ticks(ticks);
     let now = cpu::counter();
     // SAFETY: the EL1 virtual timer's comparator and control; CPACR has
# b3-no-fp-restore
diff --git a/kernel/src/arch/aarch64/switch.rs b/kernel/src/arch/aarch64/switch.rs
index c7f98cac..6c49361f 100644
--- a/kernel/src/arch/aarch64/switch.rs
+++ b/kernel/src/arch/aarch64/switch.rs
@@ -67,26 +67,6 @@ pub(crate) unsafe extern "C" fn context_switch(old_sp: *mut u64, new_sp: u64) {
         "str x9, [x0]",
         "mov sp, x1",
         "add x9, sp, #{fp}",
-        "ldp q0, q1, [x9, #0]",
-        "ldp q2, q3, [x9, #32]",
-        "ldp q4, q5, [x9, #64]",
-        "ldp q6, q7, [x9, #96]",
-        "ldp q8, q9, [x9, #128]",
-        "ldp q10, q11, [x9, #160]",
-        "ldp q12, q13, [x9, #192]",
-        "ldp q14, q15, [x9, #224]",
-        "ldp q16, q17, [x9, #256]",
-        "ldp q18, q19, [x9, #288]",
-        "ldp q20, q21, [x9, #320]",
-        "ldp q22, q23, [x9, #352]",
-        "ldp q24, q25, [x9, #384]",
-        "ldp q26, q27, [x9, #416]",
-        "ldp q28, q29, [x9, #448]",
-        "ldp q30, q31, [x9, #480]",
-        "ldr x10, [x9, #512]",
-        "msr fpcr, x10",
-        "ldr x10, [x9, #520]",
-        "msr fpsr, x10",
         "ldp x19, x20, [sp, #0]",
         "ldp x21, x22, [sp, #16]",
         "ldp x23, x24, [sp, #32]",
# b4-no-zeroing
diff --git a/kernel/src/arch/aarch64/entry.rs b/kernel/src/arch/aarch64/entry.rs
index 31a0cdf0..a04ba054 100644
--- a/kernel/src/arch/aarch64/entry.rs
+++ b/kernel/src/arch/aarch64/entry.rs
@@ -12,20 +12,6 @@ use super::switch::{DAIF_AT, FRAME_BYTES, RETURN_AT};
 /// EL0 is, for `trampoline_entry`'s contract.
 const DAIF_MASKED: u64 = 0b1111 << 6;
 
-/// Zero x1–x30, the registers a first entry to EL0 must not carry.
-macro_rules! zero_registers {
-    () => {
-        concat!(
-            "mov x1, xzr\n", "mov x2, xzr\n", "mov x3, xzr\n", "mov x4, xzr\n", "mov x5, xzr\n",
-            "mov x6, xzr\n", "mov x7, xzr\n", "mov x8, xzr\n", "mov x9, xzr\n", "mov x10, xzr\n",
-            "mov x11, xzr\n", "mov x12, xzr\n", "mov x13, xzr\n", "mov x14, xzr\n", "mov x15, xzr\n",
-            "mov x16, xzr\n", "mov x17, xzr\n", "mov x18, xzr\n", "mov x19, xzr\n", "mov x20, xzr\n",
-            "mov x21, xzr\n", "mov x22, xzr\n", "mov x23, xzr\n", "mov x24, xzr\n", "mov x25, xzr\n",
-            "mov x26, xzr\n", "mov x27, xzr\n", "mov x28, xzr\n", "mov x29, xzr\n", "mov x30, xzr\n",
-        )
-    };
-}
-
 /// A thread's first entry to EL0 at `x19` on the stack `x20`, with `x0` =
 /// `x21`: a process's argument is zero, a thread's is its own. `SPSR_EL1`
 /// zero is EL0 with every exception unmasked, and `SP_EL1` is left at the
@@ -38,7 +24,6 @@ pub(crate) extern "C" fn process_start() {
         "msr sp_el0, x20",
         "msr spsr_el1, xzr",
         "mov x0, x21",
-        zero_registers!(),
         "eret",
         unlock = sym crate::sched::driver::trampoline_entry,
     );
# b5-no-unmap-tlbi
diff --git a/kernel/src/arch/aarch64/paging.rs b/kernel/src/arch/aarch64/paging.rs
index df990ec4..b6d47d18 100644
--- a/kernel/src/arch/aarch64/paging.rs
+++ b/kernel/src/arch/aarch64/paging.rs
@@ -444,7 +444,6 @@ impl AddressSpace {
     pub fn unmap(&mut self, vaddr: UserAddr) {
         let va = vaddr.raw();
         assert!(va & (PAGE_2M - 1) == 0, "unmap: vaddr {va:#x} not 2MB-aligned");
-        let asid = self.asid.value();
         let Some(directory) = self.tables.find_directory(va) else { return };
         let i = index(va, 2);
         let entry = directory.0[i];
@@ -457,11 +456,6 @@ impl AddressSpace {
             None => entry & ADDR_2M,
         };
         self.tables.directory(va).set(i, 0);
-        if entry & TABLE != 0 {
-            tlb::asid(asid);
-        } else {
-            tlb::page(asid, va);
-        }
         crate::sched::futex::revoke_range(phys, PAGE_2M);
     }
 
# b7-floor-stored-not-programmed
diff --git a/kernel/src/arch/aarch64/irqchip.rs b/kernel/src/arch/aarch64/irqchip.rs
index 676f0e80..6da46593 100644
--- a/kernel/src/arch/aarch64/irqchip.rs
+++ b/kernel/src/arch/aarch64/irqchip.rs
@@ -305,8 +305,7 @@ fn floor_ticks() -> u64 {
 /// EL1 fire re-arms with. Returns the counter value the comparator was set
 /// from, for a caller that must relate CVAL back to it without a second read.
 fn arm_ticks(ticks: u64) -> u64 {
-    let ticks = ticks.max(floor_ticks());
-    percpu::set_armed_ticks(ticks);
+    percpu::set_armed_ticks(ticks.max(floor_ticks()));
     let now = cpu::counter();
     // SAFETY: the EL1 virtual timer's comparator and control; CPACR has
     // nothing to say about them and `CNTKCTL_EL1` keeps EL0 out.
# m5-el0-tick-no-preempt
diff --git a/kernel/src/arch/aarch64/trap.rs b/kernel/src/arch/aarch64/trap.rs
index a8c433f5..64efbbb7 100644
--- a/kernel/src/arch/aarch64/trap.rs
+++ b/kernel/src/arch/aarch64/trap.rs
@@ -154,7 +154,6 @@ fn irq(from_el0: bool) {
             let hw = &crate::hw::HW;
             hw.trace(TraceEvent { ts: hw.now(), cpu: CpuId(percpu::cpu_id()), kind: TraceKind::TimerFire });
             irqchip::end(intid);
-            crate::scheduler::do_preempt();
         } else {
             crate::preempt::set_need_resched();
             percpu::note_kernel_timer_fire();
# m6-no-demand-fill
diff --git a/kernel/src/arch/aarch64/trap.rs b/kernel/src/arch/aarch64/trap.rs
index a8c433f5..b24425e7 100644
--- a/kernel/src/arch/aarch64/trap.rs
+++ b/kernel/src/arch/aarch64/trap.rs
@@ -250,7 +250,7 @@ fn user_abort(frame: &mut Frame) {
             user_fatal(frame);
         }
         cpu::enable_interrupts();
-        let served = crate::process::handle_page_fault(frame.far, frame.esr);
+        let served = false;
         cpu::disable_interrupts();
         if served {
             percpu::set_fault_state(CpuFaultState::Normal);
# m7-tick-never-rearmed
diff --git a/kernel/src/arch/aarch64/irqchip.rs b/kernel/src/arch/aarch64/irqchip.rs
index 676f0e80..c5a9167c 100644
--- a/kernel/src/arch/aarch64/irqchip.rs
+++ b/kernel/src/arch/aarch64/irqchip.rs
@@ -361,12 +361,7 @@ pub fn stop_timer() {
 /// A timer interrupt taken: armed again for what it was last armed for, or
 /// stopped if it was stopped — the one thing that deasserts it.
 pub(super) fn rearm() {
-    match percpu::armed_ticks() {
-        0 => stop_timer_hardware(),
-        ticks => {
-            arm_ticks(ticks);
-        }
-    }
+    stop_timer_hardware();
 }
 
 /// `timer-floor`: this CPU's timer made due with interrupts masked, then
# n3-debug-panics
diff --git a/kernel/src/arch/aarch64/tlb.rs b/kernel/src/arch/aarch64/tlb.rs
index d46f19b0..0cfe8633 100644
--- a/kernel/src/arch/aarch64/tlb.rs
+++ b/kernel/src/arch/aarch64/tlb.rs
@@ -99,11 +99,11 @@ pub fn bench() {
 /// x86-64's delays an acknowledgement, and there is none here: refused.
 #[cfg(feature = "test-actuators")]
 pub fn debug_arm_ack_delay(_nanos: u64) -> u64 {
-    toyos_abi::syscall::SyscallError::NotSupported.to_u64()
+    panic!("SYS_DEBUG: AArch64 invalidates by broadcast, so there is no acknowledgement to delay")
 }
 
 /// x86-64's delays an acknowledgement, and there is none here: refused.
 #[cfg(feature = "test-actuators")]
 pub fn debug_disarm_ack_delay() -> u64 {
-    toyos_abi::syscall::SyscallError::NotSupported.to_u64()
+    panic!("SYS_DEBUG: AArch64 invalidates by broadcast, so there is no acknowledgement to delay")
 }
# n4-double-fault-panics
diff --git a/kernel/src/arch/aarch64/trap.rs b/kernel/src/arch/aarch64/trap.rs
index a8c433f5..2b4a0615 100644
--- a/kernel/src/arch/aarch64/trap.rs
+++ b/kernel/src/arch/aarch64/trap.rs
@@ -495,7 +495,7 @@ pub(crate) fn report_fault_stack() {}
 /// refused.
 #[cfg(feature = "test-actuators")]
 pub(crate) fn provoke_double_fault() -> u64 {
-    toyos_abi::syscall::SyscallError::NotSupported.to_u64()
+    panic!("SYS_DEBUG: AArch64 has no double fault to provoke")
 }
 
 /// `irq-storm`: this CPU floods itself with SGIs, sending each as soon as the

@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #589, final round, at b0db9fb

CI host completed with conclusion success on b0db9fb. The orchestrator ran the guest tests at b0db9fb: every virt_ test EXIT=0, the Fast tier EXIT=0, and all 11 patches from issuecomment-5889349325 EXIT=1 on their targets. GitHub reports the PR MERGEABLE/CLEAN.

Growth: git diff --numstat origin/main...HEAD, counting tests/ and userland/toybox/ as tests:

  • production +3200 −800
  • tests +596 −1
  • issues +149 −24

Compared with 0751537, the branch's own delta changed only in kernel/src/arch/aarch64/paging.rs, +8 lines. That is the conflict resolution; the other merge edit is boot.rs's doc comment.

Earlier blockers

  • B5 CLOSED: under b5-no-unmap-tlbi, virt_unmap_touch gives EXIT=1 at b0db9fb.
  • B7 CLOSED: under b1-no-floor and b7-floor-stored-not-programmed, virt_timer_floor gives EXIT=1 at b0db9fb.
  • The n4 NOTE is CLOSED: under n4-double-fault-panics, virt_debug_refused gives EXIT=1.

leaf() checked against the Arm ARM K.a, chapter D8 (independent oracle)

  • Write. The stage 1 data access permissions under EL1&0 are:

    • AP[2:1] = 00: EL1 RW, EL0 none
    • 01: RW at both levels
    • 10: EL1 RO, EL0 none
    • 11: RO at both levels

    AP[1] is bit 6 and AP[2] is bit 7. leaf & (AP_EL0 | AP_READ_ONLY) == AP_EL0 is exactly 01, so Write is granted only where EL0 may write.

  • Hardware dirty state. DBM (bit 51) is never written, and TCR_EL1.HD (bit 40) is clear in control_regs::TCR, so hardware never clears AP[2].

  • Read. Read is granted for any valid leaf in TTBR0 tables. Every leaf this file writes there carries AP_EL0: map_range, remap and map_window all go through user_leaf.

  • APTable. APTable is table-descriptor bits 62:61. It is honoured because TCR_EL1.HPD0 (bit 41) is clear. Every table-descriptor write in the file is phys | TABLE | VALID, with phys from Table::phys(), which is below 2^48. That covers directory:187, map_direct:241, map_window→replace:429, guard_4k:563 and init:646. So the claim that no table descriptor sets APTable holds, and the leaf's AP is the whole walk's answer.

  • Length. A block has size = PAGE_2M with base & ADDR_2M. A level-3 page has size = PAGE_4K with base & ADDR. So size - (va & (size-1)) is the bytes to the leaf's end, at least 1 in both cases.

  • User copies are scatter-gather: a window is its physical runs, each pinned #593's contract. A split window's 512 entries address one frame in order (map_window:422). So a 2 MiB window is one run, window_split cannot fire, and a typed value stays in one run. The pins are taken under the same Lock<AddressSpace> that free_and_unmap needs.

BLOCKER

  • kernel/src/arch/aarch64/paging.rs:483 — no test can fail on leaf()'s Write check on AArch64.
    • Patch W1: - toyos_userbound::Access::Write => leaf & (AP_EL0 | AP_READ_ONLY) == AP_EL0, / + toyos_userbound::Access::Write => true,
    • W1 leaves every virt_ test green. None of them makes a syscall write into a read-only page, and test_rs_abuse_readonly_copyout is x86-only (syscall asm through rdi/rsi).
    • Under W1, read(fd, CLOCK_PAGE, n) rewrites the clock frame that every AArch64 process maps (clock.rs:59).
    • Required: abuse_readonly_copyout's arms run on the AArch64 job case: read-only mmap, own .text, clock page, and the straddle. Make that one test portable rather than adding a toybox sibling.
    • Those arms must be red under W1. The straddle arm must also be red under W2: - granted.then(|| (at.phys(), size - (vaddr.raw() & (size - 1)))) / + granted.then(|| (at.phys(), size)). Under W2 the page after the last writable one is never asked.

NOTE

REMOVE

SEND BACK

🤖 Generated with Claude Code

https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs

Japabu and others added 2 commits September 29, 2026 14:44
The review's blocker: no AArch64 test could fail on `leaf()`'s Write
check. `test_rs_abuse_readonly_copyout` issued its calls with the x86
`syscall` instruction; it now goes through `toyos_abi::syscall`'s typed
wrappers, with the target named by a slice over the read-only address
(as `tls_dtv_race` does), so it builds for both architectures and its
row in the source gate's assembly exemptions goes.

- The typed-value arm was `fstat`, whose wrapper returns its `Stat`
  rather than taking an address. It is now `process_stats` of a child
  the test spawns first: the same `copy_out` path, reachable through a
  wrapper that takes the caller's `&mut`.
- Every arm runs; the exit status carries one bit per arm that let a
  write through (1 read-only mmap, 2 own text, 4 straddle, 8 clock
  page), which the kernel's own exit record prints even once a
  rewritten clock page has taken logd down. The straddle arm's search
  for the first unwritable page reports rather than panics when no page
  refuses, so a kernel that grants every write still reaches the clock
  arm.
- `tests/virtjobcase` runs it as its last job; `virt_job` builds that
  one binary for AArch64 (`build::build_toyos_bin`, the crate's other
  binaries do not all build there) and puts it on ROOT for every boot of
  the case. `virt_readonly_copyout` judges it. The x86 shared run stays
  and is declared in DRIVEN_AND_SHARED.

Also: the device-memory issue now names `user_ptr`'s direct-map copies
and `dump_crash_diagnostics` beside `read_user_word`; the arm64 track
drops the KernelArgs clause #583 made false; the assembly issue drops
its probe count.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs
@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Round 7 mutations, applying at 5792355

Each applied with git apply --check then git apply, the AArch64 image built (cargo run -- --build-only --arch aarch64) and the test kernel checked (cargo check --target aarch64-unknown-none-softfloat --features boot-actuators,test-actuators in kernel/), EXIT=0 each, then reversed with git diff --quiet clean. W1 also renames the walk's leaf binding to _leaf: without it the mutant is refused by -D unused-variables (measured: build EXIT=101).

Target: virt_readonly_copyout (the job test_rs_abuse_readonly_copyout on tests/virtjobcase). The binary's exit status carries one bit per arm that let a write through — 1 read-only mmap, 2 own .text, 4 straddle, 8 clock page — and the kernel's exit: record prints it.

  • W1 (Write always granted): EXIT=1 expected, with the job's exit: record at code=15 — every arm.
  • W2 (the leaf's length is its whole size, not the bytes to its end): EXIT=1 expected, with code=4 — the straddle arm. W2 also overstates every other copy that crosses a leaf's end, so an earlier job or boot step may die first; that is red on the target too, but not the per-arm evidence.
# w1-write-always-granted
diff --git a/kernel/src/arch/aarch64/paging.rs b/kernel/src/arch/aarch64/paging.rs
index df990ec4c..678319991 100644
--- a/kernel/src/arch/aarch64/paging.rs
+++ b/kernel/src/arch/aarch64/paging.rs
@@ -477,10 +477,10 @@ impl AddressSpace {
     /// memory goes through this, so a syscall cannot write a page the process
     /// itself may not — the clock page, a shared library's `.text`.
     pub fn leaf(&self, vaddr: UserAddr, access: toyos_userbound::Access) -> Option<(u64, u64)> {
-        let (at, leaf, size) = self.walk(vaddr)?;
+        let (at, _leaf, size) = self.walk(vaddr)?;
         let granted = match access {
             toyos_userbound::Access::Read => true,
-            toyos_userbound::Access::Write => leaf & (AP_EL0 | AP_READ_ONLY) == AP_EL0,
+            toyos_userbound::Access::Write => true,
         };
         granted.then(|| (at.phys(), size - (vaddr.raw() & (size - 1))))
     }
# w2-leaf-length-overstated
diff --git a/kernel/src/arch/aarch64/paging.rs b/kernel/src/arch/aarch64/paging.rs
index df990ec4c..c0818dba2 100644
--- a/kernel/src/arch/aarch64/paging.rs
+++ b/kernel/src/arch/aarch64/paging.rs
@@ -482,7 +482,7 @@ impl AddressSpace {
             toyos_userbound::Access::Read => true,
             toyos_userbound::Access::Write => leaf & (AP_EL0 | AP_READ_ONLY) == AP_EL0,
         };
-        granted.then(|| (at.phys(), size - (vaddr.raw() & (size - 1))))
+        granted.then(|| (at.phys(), size))
     }
 
     /// The direct-map address of `vaddr`, the leaf descriptor that maps it, and the size that leaf maps.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs

@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Review of #589, round 7, at 5792355

CI host finished with conclusion success on 5792355. The PR is MERGEABLE/CLEAN, and git merge-tree against origin/main 8ee3c51 is clean.

The orchestrator's guest runs at 5792355 (logs 589r7-*.log):

  • virt_: 12 passed, including virt_readonly_copyout PASS.
  • x86-64 abuse_readonly_copyout: 1 passed.
  • Fast tier: 244 passed.

Growth, from git diff --numstat (tests/ and userland/toybox/ counted as tests):

whole branch round 7
production +3215 −801 +15 −1
tests +755 −86 +163 −89
issues +168 −25 +25 −7

Earlier blockers

  • CLOSED: kernel/src/arch/aarch64/paging.rs:483, where no AArch64 test could fail on leaf()'s Write check.
    • W1: virt_readonly_copyout FAILED. 589r7-w1.log:527 has exit: test_rs_abuse_readonly_copy pid=14 code=15, so all four arms let a write through.
    • W2: FAILED with ===TEST_END test_rs_abuse_readonly_copyout exit=4===. 589r7-w2.log:531 has read wrote across a writable page into the read-only one at 0x100000bc000.
    • Green at the head.
  • Earlier NOTE CLOSED, the Device user leaf read through the direct map: user_ptr's copies and dump_crash_diagnostics are filed in issues/kernel/an-aarch64-crash-report-reads-through-any-user-leaf.md.
  • Earlier REMOVE done, the KernelArgs clause.

Is process_stats the same write path as the fstat it replaces?

By reading, yes.

  • Both calls end in the same SyscallContext::copy_out, reached through object::<T>(ptr, Access::Write) and leaf.
    • SYS_FSTAT: dispatch.rs:147-156.
    • SYS_PROCESS_STATS: dispatch.rs:614-620 → proc.rs:203-222.
  • process_stats adds these checks before the copy: UserAddr::checked, a3 >= size_of, READ on a Process handle, and stats_of answering Some for an exited child.
  • The control call in main into a writable ProcessStats passes every one of them. So a BadAddress in an arm can only come from copy_out.

No run has measured this yet (NOTE 1).

The straddle caveat

It is answered by measurement: green at the head with bit 4 clear, and W2 names the refusing page. The None arm stays a loud red on a correct kernel whose image ends on the window's edge, which is acceptable.

BLOCKER

None.

NOTE

  1. kernel/src/user_ptr.rs:156: the typed arm (process_stats in place of fstat) is the only thing that can fail on a typed copy-out that stops asking for Write, and no mutation has shown it can.

    • Patch T: - let (kptr, _pins) = object::<T>(ptr, Access::Write)?; / + let (kptr, _pins) = object::<T>(ptr, Access::Read)?; in copy_out.
    • It must turn abuse_readonly_copyout (x86-64) and virt_readonly_copyout red. read's bulk window is untouched by T, so only the process_stats half of refused can catch it.
  2. kernel/src/arch/x86_64/paging.rs:569,572: the claim that the x86 test is "proving what it proved" has a green arm and no red one at this head, and the test body was rewritten (typed wrappers, exit bits, process_stats).

    • Patch X1: - let (dm, rights, size) = self.walk(vaddr)?; / + let (dm, _rights, size) = self.walk(vaddr)?;, and - toyos_userbound::Access::Write => rights & STORE == STORE, / + toyos_userbound::Access::Write => true,.
    • It must turn abuse_readonly_copyout red.
  3. tests/toyos-rust-tests/src/bin/abuse_readonly_copyout.rs:66,90: the test forms &mut [u8] and &mut ProcessStats over pages nothing may write, which is a compromise the branch found. It is recorded only in the PR body's "unsure" list. Record it in issues/ with an owner and an exit condition (the ABI offering a wrapper that takes an address), or remove it.

  4. src/build.rs:2291: build_toyos_bin repeats build_toyos_bins's prologue:

    • the shared lock
    • toolchain::ensure
    • GuestEnv
    • invalidate_stale
    • the artifact hold
    • cargo_build and the read

    It differs only in --bin name for --bins. It has one caller. Share the prologue with build_toyos_bins rather than keep two entries into the same job.

  5. tests/toyos.rs:3576: under W1 the verdict is the harness's "went quiet" stall, not the job's exit status. The per-arm code sits only in the kernel exit: record that virt_job does not read. The test is red either way, but the verdict does not carry the evidence.

  6. PR body: it has no guest result at 5792355 (still "Wanted at 5792355"). The orchestrator's runs belong there, with commands, EXIT codes and logs.

REMOVE

  • PR body, "What I'm unsure of": the straddle on AArch64 is now measured (green at the head, W2 exit=4).
  • PR body, "What I'm unsure of": the clock arm's own line. It restates the test's module doc and was measured at code=15.
  • PR body: "Wanted at 5792355: …" is stale now that the runs exist.
  • tests/toyos.rs:5149-5150: the comment restates the test's exit protocol, which the test's module doc owns and VIRT_COPYOUT's doc already points to.

LAND AFTER NAMED CHANGES

🤖 Generated with Claude Code

https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs

Japabu and others added 2 commits September 29, 2026 15:51
virt_job also ends its wait on the kernel's exit: record of a non-zero
code for the job, so a job that dies before its TEST_END (W1 under logd's
death) is judged by the code, which names the arms, not by a stall.
build_toyos_bin and build_toyos_bins share their setup in TestBuild.
The comment restating the exit protocol at virt_readonly_copyout is
deleted. The &mut over pages nothing may write is filed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs
@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Mutation patches the review named, applied to 6475015, each shown to build (EXIT=0; X1's first form, rights renamed alone, is refused by -D warnings with EXIT=101 on the unused STORE, so the patch also drops it), reversed, tree clean. Guest runs are the orchestrator's: T expected red on virt_readonly_copyout and abuse_readonly_copyout; X1 red on abuse_readonly_copyout.

T (t-copyout-asks-read.patch; built for x86-64 and --arch aarch64)

diff --git a/kernel/src/user_ptr.rs b/kernel/src/user_ptr.rs
index 2f2ea160f..3c3035aa9 100644
--- a/kernel/src/user_ptr.rs
+++ b/kernel/src/user_ptr.rs
@@ -153,7 +153,7 @@ impl<'a> SyscallContext<'a> {
 
     /// Write a typed value into user memory.
     pub fn copy_out<T: UserSafe>(&self, ptr: UserAddr, value: &T) -> Result<(), SyscallError> {
-        let (kptr, _pins) = object::<T>(ptr, Access::Write)?;
+        let (kptr, _pins) = object::<T>(ptr, Access::Read)?;
         if crate::actuator::copy_meets_a_remap() {
             remap_race::hold(kptr.cast(), core::mem::size_of::<T>());
         }

X1 (x1-x86-write-always.patch; built for x86-64)

diff --git a/kernel/src/arch/x86_64/paging.rs b/kernel/src/arch/x86_64/paging.rs
index 1ad2ef7ba..89ffea37d 100644
--- a/kernel/src/arch/x86_64/paging.rs
+++ b/kernel/src/arch/x86_64/paging.rs
@@ -565,11 +565,10 @@ impl AddressSpace {
     /// cannot write a page the process itself may not — the clock page, a
     /// shared library's `.text`.
     pub fn leaf(&self, vaddr: UserAddr, access: toyos_userbound::Access) -> Option<(u64, u64)> {
-        const STORE: u64 = PAGE_USER | PAGE_WRITE;
-        let (dm, rights, size) = self.walk(vaddr)?;
+        let (dm, _rights, size) = self.walk(vaddr)?;
         let granted = match access {
             toyos_userbound::Access::Read => true,
-            toyos_userbound::Access::Write => rights & STORE == STORE,
+            toyos_userbound::Access::Write => true,
         };
         granted.then(|| (dm.phys(), size - (vaddr.raw() & (size - 1))))
     }

virt_unmap_touch's job is killed with code=-1 by design and is judged by
its line after that, so ending the wait on any non-zero exit record
failed it. The optional NOTE 5 change is reverted whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs
@Japabu

Japabu commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator Author

Orchestrator: at 20ded4d — every virt_ test (12) exit 0, Fast exit 0; at 6475015 (same kernel) T red on virt_readonly_copyout and abuse_readonly_copyout, X1 red on abuse_readonly_copyout, W1 red with the job's exit code=15. Round-7 review's named changes done; landing.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U6SVYFkdvV2t38KzNrESxs

@Japabu
Japabu added this pull request to the merge queue Sep 29, 2026
Merged via the queue into main with commit 1ccaafe Sep 29, 2026
1 check passed
@Japabu
Japabu deleted the wt/toyos-arm64 branch September 29, 2026 14:43
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