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. --- rsnix/src/gop.rs | 51 +++++++++++++++---------- rsnix/src/memory.rs | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++- rsnix/src/vt.rs | 69 +++++++++++++++++++++------------ 3 files changed, 181 insertions(+), 46 deletions(-) (limited to 'rsnix') 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 ); } } -- cgit v1.2.3