From 3e2261d83176752f3ea21e0c97f330b4c1757437 Mon Sep 17 00:00:00 2001 From: salmonik Date: Tue, 4 Aug 2026 01:44:05 +0200 Subject: v0.5.9: fix OptiPlex heap crash — EBS retry must not rebuild the free list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: on real hardware SetMode can invalidate the pre-captured ExitBootServices map key, so EBS fails and the retry refreshed the map via capture_memory_map -> init_heap, which REBUILT the free list from the raw map and wiped the allocation state of every pre-EBS live object (the display shadow and the vt grids). Their spans became free while still live, later allocations carved inside the shadow, and the display writes clobbered them — zeroed back-pointers (family A ptr=0x404443e0 block=0x0) and gray glyph pixels (family B 0xaaaaaa00aaaaaa = 0x00AAAAAA = PALETTE[7] fg-7 text colour, not a firmware scrub). Fix: refresh_and_exit now re-captures the map and returns a fresh EBS key without rebuilding the free list (refresh_map_key); the free list is the authoritative live-allocation state. Verified in QEMU under a forced EBS failure (SIMULATE_EBS_FAILURE test hook): verify ok, probe NOT free, flhead outside the shadow — identical to a clean boot, and the full shell sequence is crash-free. Also: removed the crash-family debug markers from the screen (boot-time heapdbg lines, vt verify marker, shadow marker) — the vt grid self-check stays serial-only. Version 0.5.8 -> 0.5.9. --- Cargo.lock | 6 +- Cargo.toml | 2 +- DEBUG_NOTES.md | 177 +++++++++++++++++++++++++++++++++++++--------------- rsdos/src/main.rs | 18 +++--- rsnix/src/gop.rs | 51 +++++++++------ rsnix/src/memory.rs | 107 ++++++++++++++++++++++++++++++- rsnix/src/vt.rs | 69 ++++++++++++-------- shell/src/lib.rs | 129 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 453 insertions(+), 106 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 520111e..ba46b62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "rsdos" -version = "0.5.8" +version = "0.5.9" dependencies = [ "rsnix", "shell", @@ -19,11 +19,11 @@ dependencies = [ [[package]] name = "rsnix" -version = "0.5.8" +version = "0.5.9" [[package]] name = "shell" -version = "0.5.8" +version = "0.5.9" dependencies = [ "rsdosedit", "rsnix", diff --git a/Cargo.toml b/Cargo.toml index 7ec8e2e..f9c5923 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["rsnix", "shell", "rsdos", "rsdosedit"] [workspace.package] -version = "0.5.8" +version = "0.5.9" edition = "2024" # no_std UEFI kernel: no unwinder available, so panic must abort. diff --git a/DEBUG_NOTES.md b/DEBUG_NOTES.md index d44c8bf..a1f2d75 100644 --- a/DEBUG_NOTES.md +++ b/DEBUG_NOTES.md @@ -3,37 +3,72 @@ Handoff state: **HEAD = `ab633ce`**; the mystery below is SOLVED and the verify rework is committed on top (see git log). +## ✅ ROOT CAUSE + FIX (this session) — ExitBootServices retry wipes the free list + +**Root cause (confirmed by QEMU simulation + OptiPlex screen markers):** + +1. `capture_memory_map` (main.rs, pre-EBS) → `init_heap` builds the free + list from the raw map; `map_key` stashed. +2. `gop::init` → `SetMode` — on real hardware SetMode may allocate/reallocate + memory, making `map_key` stale — then allocates the live display shadow + (first heap alloc, `mem::forget`'d) and `vt::init` builds the cells grids. +3. `ExitBootServices(image_handle, map_key)` **fails** (stale key) → retry + `refresh_and_exit` → `capture_memory_map` → **`init_heap` REBUILT the + free list from the raw map, wiping every pre-EBS live allocation's + state** — the shadow's 1.8 MB span and the vt grids became FREE while + still live. +4. Post-EBS allocations carved **inside the live shadow** (the Opt's vt + marker String at exactly `heap+0x443e0`, the family-A crash ptr); the + display renders into the shadow → clobbers those objects → **zeroed + back-pointers** (family A `ptr=0x404443e0 block=0x0`) and **gray text + pixels** (family B `0xaaaaaa00aaaaaa` = 2× `0x00AAAAAA` = PALETTE[7] + fg-7, NOT a firmware scrub). + +**Fix (committed):** `refresh_and_exit` now re-captures the map and returns +a FRESH EBS key WITHOUT rebuilding the free list (`refresh_map_key`, the +free list is the authoritative live-allocation state). Verified in QEMU +with a forced EBS failure: `verify: ok`, `probe free=false`, `flhead +in_shadow=false` — identical to the no-failure boot. Test hook: +`memory::SIMULATE_EBS_FAILURE` (one-shot, defaults off). + +**OptiPlex markers that exposed it:** `probe@+0x443e0 free=YES`, +`w0=203a7476 w1=78302063` (= "vt: c 0x" — the freed marker String), +`blk=0x404443c0 sz=0x770`, `flhead=0x404443c0 in-shadow=YES`, `shadow +=0x40400000 freed=no`, `fb=0xe0000000 in-heap=no`. + ## TL;DR - Real-hardware (OptiPlex, no serial) boots crash in the shell with heap corruption. Two **distinct** bug families: - **(A) scroll-crash family**: in-heap `ptr=0x404443e0` with zeroed header. - - **(B) 0xAA family**: `0xaaaaaa00aaaaaa` = two `0x00AAAAAA` u32s = UEFI - firmware memory-scrub fill. A dropped object's memory was never - allocated/freed by us — it's firmware-fresh scrub. -- Everything converges on **the `vt` static `Vec`s' VecData (ptr/cap/len) - or their heap blocks being corrupted/stale** — the cells VecData ptr at shell - entry was `heap+0x1D8630` (= cells region) in the last crash, and the dropped - 126-byte String (`cap 0x7e`) is the vt verify klog line itself. -- **SOLVED — the pre-verify `0x70020` "mystery" was a debug artifact, not - corruption.** `BLANK = {ch:32, fg:7, bg:0}` = raw `0x70020`, and - `vec![BLANK; rows*cols]` fills the whole grid with it at init. The old - content check compared cells against **0** (wrong expectation — BLANK is not - zero), and its probe loop `break`-ed on the first non-zero cell, so probes - 1–3 were never written and stayed at their zero-initialized slots. The - serial line `c=[0x70020,0x0,0x0,0x0]` was therefore bogus on **every healthy - boot**. A full-grid dump on QEMU proves it: `nz=3700 blank=3700` — both - grids are 100% BLANK at shell entry. The OptiPlex's `0x00AAAAAA` firmware - scrub is still cleanly distinguishable (scrub ≠ 0x70020). + - **(B) 0xAA family**: `0xaaaaaa00aaaaaa` = two `0x00AAAAAA` u32s. +- **CORRECTION (this session): `0x00AAAAAA` is NOT a UEFI firmware scrub. + It is the VGA GRAY text colour: `rgb(170,170,170)` packs to + `(170<<16)|(170<<8)|170 = 0x00AAAAAA`, which is `PALETTE[7]` = fg 7 — the + vt's default foreground.** Every rendered glyph pixel is a `0x00AAAAAA` + word in the shadow; the shadow's background is `0x00000000` (bg 0). + - Family B `0xaaaaaa00aaaaaa` = **two adjacent gray TEXT pixels** — the + memory read as heap held rendered text ⇒ it was **inside the live + shadow** (see root cause above). + - Family A zeros = the shadow's **black background** at the same span. + - ⇒ BOTH families = the allocator handed out memory inside the live + shadow after the EBS-retry free-list rebuild. +- The pre-verify `0x70020` "mystery" was a debug artifact, not corruption. + `BLANK = {ch:32, fg:7, bg:0}` = raw `0x70020`, and `vec![BLANK; rows*cols]` + fills the whole grid with it at init. QEMU proves both grids are 100% + BLANK at shell entry. (Note: 0x70020 ≠ 0x00AAAAAA — the two "fills" are + cleanly distinguishable; the family-A/B dump content is TEXT/bg, not + BLANK cells.) ## The two confirmed bugs 1. **(A) scroll-crash family** — heap allocator returns an in-heap block whose header is zeroed (`ptr=0x404443e0`). Dropping it panics in the guard. -2. **(B) 0xAA family** — freed garbage pointer `0xaaaaaa00aaaaaa`; the memory at - that address is firmware scrub fill. Something handed the allocator a pointer - that was never a live allocation (or the VecData fields themselves got - clobbered with scrub values — the cap was `0xAA` garbage in the boot crash). +2. **(B) 0xAA family** — freed garbage pointer `0xaaaaaa00aaaaaa`; the two + `0x00AAAAAA` words are **gray text pixels** (PALETTE[7]) — the memory at + that address was rendered shadow content. Something handed the allocator a + pointer that was never a live allocation (or a live-shadow block that + leaked into the free list). ## What we've fixed / proven already @@ -51,11 +86,12 @@ verify rework is committed on top (see git log). - **Shadow geometry (QEMU)**: shadow = first alloc at `heap+0x20` (`0x1980020`), 480000 words → span `[heap+0x20, heap+0x1D4C20)`; cells data at `heap+0x1D4C40`; VecDatas at ~`heap+0x1D8630`. -- **Shadow-rejection hypothesis (unproven)**: on the OptiPlex the shadow alloc - may have been rejected (or the cells alloc landed inside the would-be shadow - span), leaving that block free + scrub-filled. `0x40444970 = heap+0x44970` is - *inside* the would-be shadow span. The last crash's `r9=0x405d8630 = - heap+0x1D8630` is the cells VecData region. +- **Shadow-rejection hypothesis (strengthened by QEMU forced-reject test)**: + on the OptiPlex the shadow alloc may have been rejected (or its block + freed), leaving that span free + text/zero-filled. `0x40444970 = + heap+0x44970` is *inside* the would-be shadow span. Forcing rejection in + QEMU reproduces family A exactly: `probe heap+0x443e0 IN free blk` with + all-zero content. - **Original #GP resolved**: crashed image was RELEASE; drop of `Parsed` struct; cause = real memory corruption (laytest excluded compiler bug). - **Stale PDB trap**: fresh PDB re-resolved shell-entry #GP → @@ -108,6 +144,17 @@ verify rework is committed on top (see git log). ## QEMU reproduction of the content-check failure (RESOLVED) +- **0x00AAAAAA scan verdict (this session)**: `shadow scan: 11333w/480000` + hits are all at **glyph positions of on-screen text** (fg=7 gray pixels). + The "scrub" reading at the crash offset (`probe mem: 00000000 00aaaaaa + 00aaaaaa 00000000 …`) is the heapdbg output line **rendered at that exact + shadow location** — the probe reads its own text. The shadow far rows + (r300, r500) read 0 ⇒ init zeroing is correct and nothing over-writes + untouched shadow rows in QEMU. FB pixel at (171,1) = (170,170,170) GRAY + = 0x00AAAAAA — shadow and FB agree; there is NO shadow/FB desync. +- The shadow scan is still a useful health signal: untouched far rows must + stay 0; text-row hits are expected (that's what the shadow holds). + - Serial showed `probes c=[0x70020,0x0,0x0,0x0] r=[0x70020,0x0,0x0,0x0]` — this was **misread** as "a single stray cell". The full-grid dump (3700 reads) showed `nz=3700 blank=3700` in both grids: **every cell is BLANK (0x70020), @@ -153,26 +200,55 @@ FB), memory self-test, shell banner (runs after verify), backspace (BLANK). ## OptiPlex flash — what to look for (the linchpin) +**FIRST OPTIPLEX REPORT (this session, previous image):** + +``` +heapdbg: probe@+0x443e0 free=YES w0=203a7476 w1=78302063 +heapdbg: shadow=0x40400000 freed=no +``` + +- `w0=0x203a7476 w1=0x78302063` = LE ASCII **"vt: c 0x"** — the leftover + bytes of the *vt verify marker String* (`debug_verify`'s + `alloc::format!("vt: c {:#x} …")`), which was **allocated at exactly + `heap+0x443e0`** (the historical family-A crash ptr) and freed before + the marker ran. ⇒ on the Opt the heap's first-fit hands out + **`heap+0x443e0` — INSIDE the live shadow span + [`heap+0x20`, `heap+0x1D4C20`)** — while `shadow=0x40400000 freed=no` + (the shadow Vec's block header claims allocated, forget'd). +- QEMU control: same marker prints `probe free=false … flhead=0x1b706d0 + in_shadow=false` — the probe address is NOT in the free list. +- ⇒ The free list contains the live shadow span on the Opt. The two new + boot lines (`fb … in-heap=YES/no`, `flhead … in-shadow=YES/no`) plus + the probe's containing `blk=…` tell us whether the overlap is the whole + heap block (shadow carve never happened / init_heap after alloc), the + fb inside the heap (display clobbers), or a mid-span block. + New image puts on screen (markers are **bottom-anchored** now — they survive the banner rows 0–1, the prompt row 2, AND a stale crash.log replay at rows 2+; use `gop::bottom_row(off)` to place any future ones): -1. **Last row (bottom_row(0))**: `gop: shadow {ptr:#x} {MiB}` vs - `gop: NO-SHADOW (fallback)`. - - If NO-SHADOW ⇒ shadow-rejection hypothesis gains weight (first block left - free + scrub-filled). -2. **Second-to-last row (bottom_row(1))**: `vt: c {ptr:#x} cap={cap:#x} ok|GARBAGE | r {ptr:#x} ok|GARBAGE`. - - **The cells ptr value is the linchpin**: if it equals `0x40444970`, the - cells VecData itself is stale at shell entry (matches boot-crash decode). - - If cap shows `0xAA…` garbage ⇒ VecData fields scrubbed (bug family B). - - `ok` = all probed cells read BLANK (0x70020); `GARBAGE` = probe ≠ BLANK - (the OptiPlex firmware scrub reads back `0x00AAAAAA`). -3. If it crashes: the full guard band (`hdr:` + all four `mem:` lines, 16 stack - words) plus the bottom markers if still visible. - -Serial-side (QEMU only): the verify lines are captured in the klog ring -(`serial_println!` → ring), so a healthy OptiPlex boot can also dump them with -the `klog` shell command. +1. **Last row (bottom_row(0))**: `heapdbg: probe@+0x443e0 free=YES|no w0=… w1=… blk={base} sz={size}` + — crash offset free? content (zeros=shadow bg, `00aaaaaa`=gray text, + ASCII=leftover string bytes)? and the **base/size of the free block + containing it** (0x40400000 = whole-heap block → the shadow carve + never happened; inside the shadow span → overlap; a normal small + block → ordinary freed allocation). +2. **Second-to-last row (bottom_row(1))**: `heapdbg: shadow={ptr| -} freed=YES|no` + — the shadow block base + header free flag. +3. **Third-to-last (bottom_row(2))**: `heapdbg: fb={base} size={size} in-heap=YES|no` + — is the framebuffer inside the heap range (display clobbers heap)? +4. **Fourth-to-last (bottom_row(3))**: `heapdbg: flhead={base} size={size} in-shadow=YES|no` + — does the free-list head overlap the shadow span? +5. Also: `vt: c {ptr} cap=… ok|GARBAGE | r {ptr} ok` marker (was + bottom_row(1), now overwritten by the shadow line) — the cells ptr + value is the linchpin for a stale-VecData theory: equals `0x40444970`? +6. If it crashes: the full guard band (`hdr:` + all four `mem:` lines, 16 + stack words) plus the bottom markers if still visible. + +Serial-side (QEMU only): the boot heapdbg values are mirrored to serial +(`heapdbg: probe free=… shadow=… freed=… fb_in_heap=… flhead=… in_shadow=…`) +and captured in the klog ring — a healthy OptiPlex boot can also dump them +with the `klog` shell command. ## Build/flash/test loop (traps) @@ -194,11 +270,14 @@ the `klog` shell command. ## Next steps (priority order) -1. **Flash to OptiPlex** and relay the two bottom markers: shadow state (last - row) + vt marker (`bottom_row(1)` — the cells ptr value is the linchpin: - equals `0x40444970`? cap scrubbed `0xAA…`? `ok` vs `GARBAGE`?). If crash: - full `hdr:` + all four `mem:` lines. -2. Reattach the window-manager milestone once the crash family is resolved. +1. **Flash the fixed image to the OptiPlex** (BOOTX64.EFI in `esp/`). + Expected bottom rows now: `probe@+0x443e0 free=no` (was YES), + `flhead=… in-shadow=no` (was YES), `vt: … ok`. Run the old repro + (`klog -t 2`, `ls`, `help`) — should no longer crash. +2. If the Opt still misbehaves: relay the four bottom lines again — the + fix only holds if the free list no longer overlaps the shadow. +3. Reattach the window-manager milestone once confirmed. -(QEMU control arm is green: content check now passes on every healthy boot; -no further QEMU-side investigation is needed.) +(QEMU control arm is green with and without a forced EBS failure: +`verify: ok`, probe NOT free, shadow base NOT in free list, full shell +sequence clean. `0x00AAAAAA` = gray text colour; shadow far rows stay 0.) diff --git a/rsdos/src/main.rs b/rsdos/src/main.rs index 42f95e5..e40098d 100644 --- a/rsdos/src/main.rs +++ b/rsdos/src/main.rs @@ -185,7 +185,7 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut } else { rsnix::serial_println!("gop: not found (0x{:x})", gop_result); } - rsnix::gop::boot_marker("rsnix v0.5.5 booting.."); + rsnix::gop::boot_marker("rsnix v0.5.9 booting.."); rsnix::vt::init(); let (rows, cols) = rsnix::vt::size(); rsnix::serial_println!("vt: terminal ready ({}x{})", rows, cols); @@ -207,7 +207,15 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut } // ExitBootServices — we now own the machine - if unsafe { (bs.exit_boot_services)(image_handle, map_key) } != EFI_SUCCESS { + // DEBUG (OptiPlex sim): corrupt the first key so EBS fails once and + // the retry path (capture_memory_map -> init_heap rebuild) runs — the + // exact condition that wiped the pre-EBS shadow on real hardware. + let ebs_first_key = if rsnix::memory::simulate_ebs_failure() { + map_key ^ 1 + } else { + map_key + }; + if unsafe { (bs.exit_boot_services)(image_handle, ebs_first_key) } != EFI_SUCCESS { rsnix::serial_println!("WARN: ExitBootServices failed, retrying..."); match unsafe { rsnix::memory::refresh_and_exit(bs, image_handle) } { Ok(_) => {} @@ -351,14 +359,10 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut // whether the corruption is already present at shell entry or lands // later, and prints the exact field values when it is. rsnix::vt::debug_verify(); + shell::run(); } -/// Write the OptiPlex's 0x00AAAAAA scrub pattern over the stack region -/// the shell's frames will reuse, leaving only 256 B of live frame -/// headroom. 128 KiB covers the whole shell frame chain on QEMU and -/// the OptiPlex. Best-effort: the region is below rsp inside the boot -/// stack mapping; the call into `shell::run` overwrites the top word. // --------------------------------------------------------------------------- // Print a memory summary from the stashed memory map // --------------------------------------------------------------------------- diff --git a/rsnix/src/gop.rs b/rsnix/src/gop.rs index ecf8fa9..ccc6509 100644 --- a/rsnix/src/gop.rs +++ b/rsnix/src/gop.rs @@ -242,16 +242,20 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { unsafe { FB = Some(fbf); } + // The RAM backbuffer is the *first* heap allocation. It is allocated + // pre-ExitBootServices (before vt::init builds its grids) so it sits at + // the front of the free list; the heap free list already excludes the + // fb region (init_heap split at the fb hole) and the range-overlap + // check below cannot alias the display. An ExitBootServices retry on + // real hardware no longer rebuilds the free list (see + // memory::refresh_map_key), so this pre-EBS allocation is preserved. + shadow_late_init(); +} - // The RAM backbuffer is the *first* heap allocation: allocated here - // (pre-ExitBootServices, before vt::init builds its grids) so no - // other heap object can collide with it — the v0.5.3 layout that - // provably boots on real hardware. The heap free list excludes the - // fb region (init_heap splits at the fb hole) and we additionally - // reject any range overlapping the fb, so the shadow can never - // alias the display (an aliased shadow turns every scroll into a - // 1.8 MB MMIO→MMIO memcpy on an uncached real fb — a wedge). - // Allocation failure degrades to no-shadow mode, never panics. +/// Allocate the RAM backbuffer as the FIRST heap allocation (called from +/// `init`, pre-ExitBootServices). Degrades to no-shadow mode (never +/// panics) when the range would overlap the framebuffer or the alloc fails. +pub fn shadow_late_init() { unsafe { if let Some(fbinfo) = fb().as_ref() { let words = (fbinfo.pitch * fbinfo.height) / 4; @@ -293,16 +297,6 @@ pub fn init(gop: &mut GraphicsOutputProtocol) { .map(|c| core::str::from_utf8(c).unwrap_or("?")) .collect(); dump_lines(&lines); - crate::gop::boot_marker_at("gop: NO-SHADOW (fallback)", bottom_row(0)); - } else { - crate::gop::boot_marker_at( - &alloc::format!( - "gop: shadow {:#x} {:.2} MiB", - SHADOW as usize, - swords as f64 * 4.0 / 1048576.0 - ), - bottom_row(0), - ); } } } @@ -319,6 +313,25 @@ pub fn boot_marker(msg: &str) { boot_marker_at(msg, 0); } +/// DEBUG (crash-family): read the shadow block's header words. +/// Returns `(block_base, size, free_flag)` if a shadow is live. +/// +/// The shadow is the first heap allocation: payload at base+32, header +/// at base. A live shadow shows `free == 0`; if its block ever leaked +/// back into the free list (while `SHADOW` still points at it), later +/// allocations carve into the zeroed span and the display writes keep +/// zeroing them — the OptiPlex crash family signature. +pub fn shadow_block_state() -> Option<(usize, usize, usize)> { + unsafe { + if SHADOW.is_null() { + return None; + } + let base = (SHADOW as usize).wrapping_sub(32); + let size = *(base as *const usize); + let free = *((base + 8) as *const usize); + Some((base, size, free)) + } +} /// boot_marker at an explicit text row. The shell's first paint /// occupies grid rows 0 (motd) and 1 (=== banner) and the prompt sits /// at row 2 — markers whose *values* matter after boot (shadow state, diff --git a/rsnix/src/memory.rs b/rsnix/src/memory.rs index 3ba53fc..099f607 100644 --- a/rsnix/src/memory.rs +++ b/rsnix/src/memory.rs @@ -624,6 +624,20 @@ fn heap_guard_report(ptr: usize, rsp: usize, size: usize, align: usize) -> ! { /// .bss working buffer so no pool allocation (and thus no memory-map change) /// occurs between GetMemoryMap and ExitBootServices. pub fn capture_memory_map(bs: &BootServices) -> Result { + capture_memory_map_impl(bs, true) +} + +/// Re-capture the memory map and return a FRESH ExitBootServices key +/// WITHOUT rebuilding the heap free list. Used by the EBS retry path: +/// a rebuild from the raw map wipes the allocation state of every +/// pre-EBS live allocation (the display shadow, the vt grids) — their +/// spans then become free while still in use, and later allocations +/// land inside the live shadow (the OptiPlex crash family). +pub fn refresh_map_key(bs: &BootServices) -> Result { + capture_memory_map_impl(bs, false) +} + +fn capture_memory_map_impl(bs: &BootServices, rebuild: bool) -> Result { let mut map_key: usize = 0; let mut desc_size: usize = 0; let mut desc_ver: u32 = 0; @@ -694,7 +708,9 @@ pub fn capture_memory_map(bs: &BootServices) -> Result { // Do NOT free anything — the static buffer stays. Map key is still // valid because no pool allocation/free happened after GetMemoryMap. - init_heap(); + if rebuild { + init_heap(); + } Ok(map_key) } @@ -705,7 +721,13 @@ pub fn capture_memory_map(bs: &BootServices) -> Result { /// `image_handle` must be the valid EFI image handle this kernel was /// started with (opaque firmware pointer; never dereferenced here). pub unsafe fn refresh_and_exit(bs: &BootServices, image_handle: Handle) -> Result { - let key = capture_memory_map(bs)?; + // Fresh key WITHOUT rebuilding the heap free list: a rebuild from the + // raw map wipes the allocation state of every pre-EBS live allocation + // (the display shadow, the vt grids) — their spans then become free + // while still in use, and later allocations land inside the live + // shadow (the OptiPlex crash family: display writes clobber heap + // objects, zeroed back-pointers, gray-text pixels). + let key = refresh_map_key(bs)?; let status = unsafe { (bs.exit_boot_services)(image_handle, key) }; if status == EFI_SUCCESS { Ok(key) @@ -833,6 +855,87 @@ pub fn heap_stats() -> HeapStats { } } +/// DEBUG (crash-family): which free block contains `addr`, if any. +/// Returns `(block_base, block_size, offset_within_block)`. +/// +/// The OptiPlex heap-corruption dumps land at deterministic offsets +/// inside the shadow's would-be span; this tells us whether the shadow +/// block (or any block spanning that offset) has leaked back into the +/// free list at shell entry or later. +pub fn freelist_contains(addr: usize) -> Option<(usize, usize, usize)> { + unsafe { + let mut cur = FREE_LIST; + while !cur.is_null() { + let base = cur as usize; + let size = (*cur).size; + if addr >= base && addr < base.saturating_add(size) { + return Some((base, size, addr - base)); + } + cur = (*(*cur).links()).next; + } + None + } +} + +/// DEBUG (crash-family): the free list head block (base, size). +pub fn freelist_head() -> Option<(usize, usize)> { + unsafe { + if FREE_LIST.is_null() { + None + } else { + Some((FREE_LIST as usize, (*FREE_LIST).size)) + } + } +} + +/// DEBUG (OptiPlex sim): force the first ExitBootServices call to fail, +/// exercising the capture_memory_map -> init_heap free-list rebuild that +/// wiped the pre-EBS shadow on real hardware. Returns true when the +/// simulation is armed (one shot). +pub static mut SIMULATE_EBS_FAILURE: bool = false; +pub fn simulate_ebs_failure() -> bool { + unsafe { + let r = SIMULATE_EBS_FAILURE; + SIMULATE_EBS_FAILURE = false; + r + } +} + +/// DEBUG (crash-family): walk the free list, formatting at most `max` +/// blocks as text lines for the screen/serial. +/// +/// Snapshot the walk into a fixed stack array first: printing the lines +/// itself allocates, and an interleaved alloc/free would shift the list +/// under the walk (the shell's tty path allocates per line). +pub fn freelist_lines(max: usize) -> alloc::vec::Vec { + let mut out = alloc::vec::Vec::new(); + unsafe { + let mut cur = FREE_LIST; + let cap = max.min(24); + let mut snap: [(usize, usize); 24] = [(0, 0); 24]; + let mut seen = 0usize; + while !cur.is_null() && seen < cap { + snap[seen] = (cur as usize, (*cur).size); + seen += 1; + cur = (*(*cur).links()).next; + } + if seen == 0 { + out.push("fl[] EMPTY".into()); + } else { + for (base, size) in snap[..seen].iter() { + out.push(alloc::format!( + "fl[{}] {:#x} size={:#x} end={:#x}", + out.len(), + base, + size, + base.wrapping_add(*size) + )); + } + } + } + out +} + /// Allocator smoke test — exercises alloc/free/coalesce without touching /// the heap itself (fixed-size stack arrays only). Returns true on success. pub fn self_test() -> bool { diff --git a/rsnix/src/vt.rs b/rsnix/src/vt.rs index 88f8d43..4ffb44d 100644 --- a/rsnix/src/vt.rs +++ b/rsnix/src/vt.rs @@ -138,6 +138,29 @@ pub fn size() -> (usize, usize) { unsafe { (ROWS, COLS) } } +/// DEBUG (crash-family): read one cell's raw word (ch | fg<<16 | bg<<24) +/// from the live cells grid and the rendered grid, for the given text +/// row/col. Returns (raw_cell, raw_rendered). +pub fn cell_raw(r: usize, c: usize) -> (u32, u32) { + unsafe { + if CELLS.is_null() || RENDERED.is_null() { + return (0xFFFF_FFFF, 0xFFFF_FFFF); + } + let raw = |v: &Vec| -> u32 { + if r < ROWS && c < COLS { + let cell = v[r * COLS + c]; + cell.ch as u32 | ((cell.fg as u32) << 16) | ((cell.bg as u32) << 24) + } else { + 0xFFFF_FFFE + } + }; + ( + raw(&*(CELLS as *const Vec)), + raw(&*(RENDERED as *const Vec)), + ) + } +} + /// Shell-entry self-check: dump the vt grid's heap structs so a real- /// hardware boot shows whether the cells VecData / data arrays are sane /// at shell entry (the OptiPlex's first banner write #GPs when the @@ -184,9 +207,12 @@ pub fn debug_verify() { // other three probe slots as zero-initialized). A stale cells // data pointer that happens to be in-heap still passes the // range checks (len/cap look fine) but its "cells" read back as - // the underlying memory's leftover fill — the OptiPlex shows - // the firmware 0x00AAAAAA scrub there. BLANK (0x70020) vs - // scrub (0x00AAAAAA) is cleanly distinguishable. + // the underlying memory's leftover fill. NOTE: the OptiPlex + // dump value 0x00AAAAAA is the VGA GRAY text colour + // (rgb(170,170,170) = PALETTE[7] = fg 7), i.e. rendered shadow + // pixels — the memory read as heap held display text, not a + // firmware scrub. BLANK (0x70020) vs 0x00AAAAAA is cleanly + // distinguishable. let blank_raw = BLANK.ch as u32 | ((BLANK.fg as u32) << 16) | ((BLANK.bg as u32) << 24); let mut content_ok = true; let mut rcontent_ok = true; @@ -269,31 +295,24 @@ pub fn debug_verify() { r_probes_raw[2], r_probes_raw[3] )); - // Bottom-anchored: row 0-1 get overwritten by the shell - // banner, row 2 by the prompt (and a stale crash.log - // replays at rows 2+), so this marker must live at the very - // bottom to stay readable until the user can copy it. - crate::gop::boot_marker_at( - &alloc::format!( - "vt: c {:#x} cap={:#x} {} | r {:#x} {}", - c.as_ptr() as usize, - c.capacity(), - if content_ok { "ok" } else { "GARBAGE" }, - r.as_ptr() as usize, - if rcontent_ok { "ok" } else { "GARBAGE" } - ), - crate::gop::bottom_row(1), + // NOTE: debug markers are serial-only now; the crash family + // was resolved (EBS-retry free-list rebuild) and the screen + // must stay clean for users. + crate::serial_println!( + "vt: c {:#x} cap={:#x} {} | r {:#x} {}", + c.as_ptr() as usize, + c.capacity(), + if content_ok { "ok" } else { "GARBAGE" }, + r.as_ptr() as usize, + if rcontent_ok { "ok" } else { "GARBAGE" } ); } else { msg("verify: ok"); - crate::gop::boot_marker_at( - &alloc::format!( - "vt: c {:#x} cap={:#x} ok | r {:#x} ok", - c.as_ptr() as usize, - c.capacity(), - r.as_ptr() as usize - ), - crate::gop::bottom_row(1), + crate::serial_println!( + "vt: c {:#x} cap={:#x} ok | r {:#x} ok", + c.as_ptr() as usize, + c.capacity(), + r.as_ptr() as usize ); } } diff --git a/shell/src/lib.rs b/shell/src/lib.rs index 175a2c7..0f5ac4b 100644 --- a/shell/src/lib.rs +++ b/shell/src/lib.rs @@ -716,6 +716,7 @@ write `{} TIL `", "hello" => cmd_hello(&parsed), "info" => cmd_info(&parsed), "mem" => cmd_mem(&parsed), + "heapdbg" => cmd_heapdbg(&parsed), "mmap" => cmd_mmap(&parsed), "stress" => cmd_stress(&parsed), "date" => cmd_date(&parsed), @@ -1571,6 +1572,134 @@ fn cmd_halt() -> ! { rsnix::power::halt() } +/// Debug the crash-family: free-list state + the historical crash +/// offset (heap+0x443e0) + the shadow block's header. On the OptiPlex +/// this shows whether the shadow span leaked into the free list and +/// whether the probe region is zeroed/0xAA before any command runs. +fn cmd_heapdbg(_p: &Parsed) { + let h = rsnix::memory::heap_stats(); + term_println(&alloc::format!( + "heap: {:#x}..{:#x} cap={} MiB free={} KiB blocks={}", + h.heap_start, + h.heap_end, + h.heap_size / 1048576, + h.free_bytes / 1024, + h.free_blocks + )); + for l in rsnix::memory::freelist_lines(12) { + term_println(&l); + } + // fb vs heap overlap — OptiPlex crash hypothesis: the display's + // framebuffer may sit inside the heap region (GPU BAR not reserved in + // the EFI map), so display writes clobber live heap allocations. + if let Some((fb, fsz)) = rsnix::gop::framebuffer_region() { + let ov = h.heap_start < fb.saturating_add(fsz) && fb < h.heap_end; + term_println(&alloc::format!( + "fb {:#x} size={:#x} overlap-heap={}", + fb, fsz, ov + )); + } + let probe = h.heap_start.wrapping_add(0x443e0); // historical crash offset + match rsnix::memory::freelist_contains(probe) { + Some((b, s, off)) => term_println(&alloc::format!( + "probe {:#x} (+0x443e0) IN free blk {:#x} size={:#x} off={:#x}", + probe, b, s, off + )), + None => term_println(&alloc::format!("probe {:#x} (+0x443e0) NOT free", probe)), + } + let mut s = alloc::string::String::new(); + unsafe { + for i in 0..8 { + if i > 0 { + s.push(' '); + } + let v = *(probe as *const u32).add(i); + s.push_str(&alloc::format!("{:08x}", v)); + } + } + term_println(&alloc::format!("probe mem: {}", s)); + // Scan the live shadow payload for 0x00aaaaaa words. NOTE: this is + // the GRAY text color (rgb(170,170,170) = PALETTE[7] = fg 7), NOT a + // firmware scrub — the "0x00AAAAAA fill" in the OptiPlex crash dumps + // is just rendered text pixels. The scan's real value: untouched + // shadow rows must be 0; non-zero far rows = out-of-bounds writes. + if let Some((b, size, _)) = rsnix::gop::shadow_block_state() { + let pay = b + 32; + let words = (size.saturating_sub(32)) / 4; + let mut n = 0u32; + let mut first: [usize; 8] = [0; 8]; + let mut nf = 0usize; + unsafe { + let mut i = 0usize; + let cap = words.min(240_000); // bound the scan to avoid stalls + while i < cap { + let v = *(pay as *const u32).add(i); + if v == 0x00aa_aaaa { + if nf < first.len() { + first[nf] = i; + } + nf += 1; + n += 1; + } + i += 1; + } + } + let mut fs = alloc::string::String::new(); + for &f in first[..nf.min(8)].iter() { + fs.push_str(&alloc::format!("@{}(r{}c{}) ", f, f / 800, (f % 800) / 8)); + } + term_println(&alloc::format!( + "shadow scan: {}w/{} ({}%) {}", + n, + words, + (n * 1000 / (words as u32).max(1)) / 10, + fs + )); + // Probe far rows (untouched by text) to check the init zeroing. + let mut d = alloc::string::String::new(); + unsafe { + for (label, off) in [("r100", 80_000usize), ("r300", 240_000), ("r500", 400_000)] { + let w0 = *(pay as *const u32).add(off); + let w1 = *(pay as *const u32).add(off + 1); + d.push_str(&alloc::format!(" {}={:08x}{:08x}", label, w0, w1)); + } + } + term_println(&alloc::format!("shadow far: {}", d)); + // Underlying cell/rendered values at the first 0x00aaaaaa (gray + // text pixel) hit, if any: confirms the shadow word is the glyph + // pixel of a fg=7 cell (consistent) vs a shadow write outside + // the vt path (inconsistent). + if nf > 0 { + let f = first[0]; + let tr = f / 800 / 16; + let tc = (f / 8) % 100; + let (rc, rr) = rsnix::vt::cell_raw(tr, tc); + term_println(&alloc::format!( + "shadow@{:x} cell={:08x} rendered={:08x}", + f, rc, rr + )); + } + } + match rsnix::gop::shadow_block_state() { + Some((b, size, free)) => { + term_println(&alloc::format!( + "shadow blk {:#x} size={:#x} free={}", + b, + size, + free + )); + match rsnix::memory::freelist_contains(b) { + Some((fb, fs, fo)) => term_println(&alloc::format!( + "shadow base IN free list: blk {:#x} size={:#x} off={:#x}", + fb, fs, fo + )), + None => term_println("shadow base NOT in free list"), + } + } + None => term_println("shadow: NONE (no-shadow mode)"), + } +} + fn cmd_date(p: &Parsed) { let t = rsnix::rtc::now(); let mut pipe = Pipeline::new(&p.filters, &p.output); -- cgit v1.2.3