summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-04 01:44:05 +0200
committersalmonik <salmonik@tuta.io>2026-08-04 01:44:05 +0200
commit3e2261d83176752f3ea21e0c97f330b4c1757437 (patch)
tree8bbc92f917121fafaf193f1af1c42fba624abda9
parentv0.5.8: heap-free QR encoder + crash-dump QR on framebuffer (diff)
downloadrs-dos-3e2261d83176752f3ea21e0c97f330b4c1757437.tar.gz
rs-dos-3e2261d83176752f3ea21e0c97f330b4c1757437.zip
v0.5.9: fix OptiPlex heap crash — EBS retry must not rebuild the free list
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.
-rw-r--r--Cargo.lock6
-rw-r--r--Cargo.toml2
-rw-r--r--DEBUG_NOTES.md177
-rw-r--r--rsdos/src/main.rs18
-rw-r--r--rsnix/src/gop.rs51
-rw-r--r--rsnix/src/memory.rs107
-rw-r--r--rsnix/src/vt.rs69
-rw-r--r--shell/src/lib.rs129
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
4 4
5[[package]] 5[[package]]
6name = "rsdos" 6name = "rsdos"
7version = "0.5.8" 7version = "0.5.9"
8dependencies = [ 8dependencies = [
9 "rsnix", 9 "rsnix",
10 "shell", 10 "shell",
@@ -19,11 +19,11 @@ dependencies = [
19 19
20[[package]] 20[[package]]
21name = "rsnix" 21name = "rsnix"
22version = "0.5.8" 22version = "0.5.9"
23 23
24[[package]] 24[[package]]
25name = "shell" 25name = "shell"
26version = "0.5.8" 26version = "0.5.9"
27dependencies = [ 27dependencies = [
28 "rsdosedit", 28 "rsdosedit",
29 "rsnix", 29 "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"
3members = ["rsnix", "shell", "rsdos", "rsdosedit"] 3members = ["rsnix", "shell", "rsdos", "rsdosedit"]
4 4
5[workspace.package] 5[workspace.package]
6version = "0.5.8" 6version = "0.5.9"
7edition = "2024" 7edition = "2024"
8 8
9# no_std UEFI kernel: no unwinder available, so panic must abort. 9# 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 @@
3Handoff state: **HEAD = `ab633ce`**; the mystery below is SOLVED and the 3Handoff state: **HEAD = `ab633ce`**; the mystery below is SOLVED and the
4verify rework is committed on top (see git log). 4verify rework is committed on top (see git log).
5 5
6## ✅ ROOT CAUSE + FIX (this session) — ExitBootServices retry wipes the free list
7
8**Root cause (confirmed by QEMU simulation + OptiPlex screen markers):**
9
101. `capture_memory_map` (main.rs, pre-EBS) → `init_heap` builds the free
11 list from the raw map; `map_key` stashed.
122. `gop::init` → `SetMode` — on real hardware SetMode may allocate/reallocate
13 memory, making `map_key` stale — then allocates the live display shadow
14 (first heap alloc, `mem::forget`'d) and `vt::init` builds the cells grids.
153. `ExitBootServices(image_handle, map_key)` **fails** (stale key) → retry
16 `refresh_and_exit` → `capture_memory_map` → **`init_heap` REBUILT the
17 free list from the raw map, wiping every pre-EBS live allocation's
18 state** — the shadow's 1.8 MB span and the vt grids became FREE while
19 still live.
204. Post-EBS allocations carved **inside the live shadow** (the Opt's vt
21 marker String at exactly `heap+0x443e0`, the family-A crash ptr); the
22 display renders into the shadow → clobbers those objects → **zeroed
23 back-pointers** (family A `ptr=0x404443e0 block=0x0`) and **gray text
24 pixels** (family B `0xaaaaaa00aaaaaa` = 2× `0x00AAAAAA` = PALETTE[7]
25 fg-7, NOT a firmware scrub).
26
27**Fix (committed):** `refresh_and_exit` now re-captures the map and returns
28a FRESH EBS key WITHOUT rebuilding the free list (`refresh_map_key`, the
29free list is the authoritative live-allocation state). Verified in QEMU
30with a forced EBS failure: `verify: ok`, `probe free=false`, `flhead
31in_shadow=false` — identical to the no-failure boot. Test hook:
32`memory::SIMULATE_EBS_FAILURE` (one-shot, defaults off).
33
34**OptiPlex markers that exposed it:** `probe@+0x443e0 free=YES`,
35`w0=203a7476 w1=78302063` (= "vt: c 0x" — the freed marker String),
36`blk=0x404443c0 sz=0x770`, `flhead=0x404443c0 in-shadow=YES`, `shadow
37=0x40400000 freed=no`, `fb=0xe0000000 in-heap=no`.
38
6## TL;DR 39## TL;DR
7 40
8- Real-hardware (OptiPlex, no serial) boots crash in the shell with heap 41- Real-hardware (OptiPlex, no serial) boots crash in the shell with heap
9 corruption. Two **distinct** bug families: 42 corruption. Two **distinct** bug families:
10 - **(A) scroll-crash family**: in-heap `ptr=0x404443e0` with zeroed header. 43 - **(A) scroll-crash family**: in-heap `ptr=0x404443e0` with zeroed header.
11 - **(B) 0xAA family**: `0xaaaaaa00aaaaaa` = two `0x00AAAAAA` u32s = UEFI 44 - **(B) 0xAA family**: `0xaaaaaa00aaaaaa` = two `0x00AAAAAA` u32s.
12 firmware memory-scrub fill. A dropped object's memory was never 45- **CORRECTION (this session): `0x00AAAAAA` is NOT a UEFI firmware scrub.
13 allocated/freed by us — it's firmware-fresh scrub. 46 It is the VGA GRAY text colour: `rgb(170,170,170)` packs to
14- Everything converges on **the `vt` static `Vec<Cell>`s' VecData (ptr/cap/len) 47 `(170<<16)|(170<<8)|170 = 0x00AAAAAA`, which is `PALETTE[7]` = fg 7 — the
15 or their heap blocks being corrupted/stale** — the cells VecData ptr at shell 48 vt's default foreground.** Every rendered glyph pixel is a `0x00AAAAAA`
16 entry was `heap+0x1D8630` (= cells region) in the last crash, and the dropped 49 word in the shadow; the shadow's background is `0x00000000` (bg 0).
17 126-byte String (`cap 0x7e`) is the vt verify klog line itself. 50 - Family B `0xaaaaaa00aaaaaa` = **two adjacent gray TEXT pixels** — the
18- **SOLVED — the pre-verify `0x70020` "mystery" was a debug artifact, not 51 memory read as heap held rendered text ⇒ it was **inside the live
19 corruption.** `BLANK = {ch:32, fg:7, bg:0}` = raw `0x70020`, and 52 shadow** (see root cause above).
20 `vec![BLANK; rows*cols]` fills the whole grid with it at init. The old 53 - Family A zeros = the shadow's **black background** at the same span.
21 content check compared cells against **0** (wrong expectation — BLANK is not 54 - ⇒ BOTH families = the allocator handed out memory inside the live
22 zero), and its probe loop `break`-ed on the first non-zero cell, so probes 55 shadow after the EBS-retry free-list rebuild.
23 1–3 were never written and stayed at their zero-initialized slots. The 56- The pre-verify `0x70020` "mystery" was a debug artifact, not corruption.
24 serial line `c=[0x70020,0x0,0x0,0x0]` was therefore bogus on **every healthy 57 `BLANK = {ch:32, fg:7, bg:0}` = raw `0x70020`, and `vec![BLANK; rows*cols]`
25 boot**. A full-grid dump on QEMU proves it: `nz=3700 blank=3700` — both 58 fills the whole grid with it at init. QEMU proves both grids are 100%
26 grids are 100% BLANK at shell entry. The OptiPlex's `0x00AAAAAA` firmware 59 BLANK at shell entry. (Note: 0x70020 ≠ 0x00AAAAAA — the two "fills" are
27 scrub is still cleanly distinguishable (scrub ≠ 0x70020). 60 cleanly distinguishable; the family-A/B dump content is TEXT/bg, not
61 BLANK cells.)
28 62
29## The two confirmed bugs 63## The two confirmed bugs
30 64
311. **(A) scroll-crash family** — heap allocator returns an in-heap block whose 651. **(A) scroll-crash family** — heap allocator returns an in-heap block whose
32 header is zeroed (`ptr=0x404443e0`). Dropping it panics in the guard. 66 header is zeroed (`ptr=0x404443e0`). Dropping it panics in the guard.
332. **(B) 0xAA family** — freed garbage pointer `0xaaaaaa00aaaaaa`; the memory at 672. **(B) 0xAA family** — freed garbage pointer `0xaaaaaa00aaaaaa`; the two
34 that address is firmware scrub fill. Something handed the allocator a pointer 68 `0x00AAAAAA` words are **gray text pixels** (PALETTE[7]) — the memory at
35 that was never a live allocation (or the VecData fields themselves got 69 that address was rendered shadow content. Something handed the allocator a
36 clobbered with scrub values — the cap was `0xAA` garbage in the boot crash). 70 pointer that was never a live allocation (or a live-shadow block that
71 leaked into the free list).
37 72
38## What we've fixed / proven already 73## What we've fixed / proven already
39 74
@@ -51,11 +86,12 @@ verify rework is committed on top (see git log).
51- **Shadow geometry (QEMU)**: shadow = first alloc at `heap+0x20` (`0x1980020`), 86- **Shadow geometry (QEMU)**: shadow = first alloc at `heap+0x20` (`0x1980020`),
52 480000 words → span `[heap+0x20, heap+0x1D4C20)`; cells data at 87 480000 words → span `[heap+0x20, heap+0x1D4C20)`; cells data at
53 `heap+0x1D4C40`; VecDatas at ~`heap+0x1D8630`. 88 `heap+0x1D4C40`; VecDatas at ~`heap+0x1D8630`.
54- **Shadow-rejection hypothesis (unproven)**: on the OptiPlex the shadow alloc 89- **Shadow-rejection hypothesis (strengthened by QEMU forced-reject test)**:
55 may have been rejected (or the cells alloc landed inside the would-be shadow 90 on the OptiPlex the shadow alloc may have been rejected (or its block
56 span), leaving that block free + scrub-filled. `0x40444970 = heap+0x44970` is 91 freed), leaving that span free + text/zero-filled. `0x40444970 =
57 *inside* the would-be shadow span. The last crash's `r9=0x405d8630 = 92 heap+0x44970` is *inside* the would-be shadow span. Forcing rejection in
58 heap+0x1D8630` is the cells VecData region. 93 QEMU reproduces family A exactly: `probe heap+0x443e0 IN free blk` with
94 all-zero content.
59- **Original #GP resolved**: crashed image was RELEASE; drop of `Parsed` struct; 95- **Original #GP resolved**: crashed image was RELEASE; drop of `Parsed` struct;
60 cause = real memory corruption (laytest excluded compiler bug). 96 cause = real memory corruption (laytest excluded compiler bug).
61- **Stale PDB trap**: fresh PDB re-resolved shell-entry #GP → 97- **Stale PDB trap**: fresh PDB re-resolved shell-entry #GP →
@@ -108,6 +144,17 @@ verify rework is committed on top (see git log).
108 144
109## QEMU reproduction of the content-check failure (RESOLVED) 145## QEMU reproduction of the content-check failure (RESOLVED)
110 146
147- **0x00AAAAAA scan verdict (this session)**: `shadow scan: 11333w/480000`
148 hits are all at **glyph positions of on-screen text** (fg=7 gray pixels).
149 The "scrub" reading at the crash offset (`probe mem: 00000000 00aaaaaa
150 00aaaaaa 00000000 …`) is the heapdbg output line **rendered at that exact
151 shadow location** — the probe reads its own text. The shadow far rows
152 (r300, r500) read 0 ⇒ init zeroing is correct and nothing over-writes
153 untouched shadow rows in QEMU. FB pixel at (171,1) = (170,170,170) GRAY
154 = 0x00AAAAAA — shadow and FB agree; there is NO shadow/FB desync.
155- The shadow scan is still a useful health signal: untouched far rows must
156 stay 0; text-row hits are expected (that's what the shadow holds).
157
111- Serial showed `probes c=[0x70020,0x0,0x0,0x0] r=[0x70020,0x0,0x0,0x0]` — this 158- Serial showed `probes c=[0x70020,0x0,0x0,0x0] r=[0x70020,0x0,0x0,0x0]` — this
112 was **misread** as "a single stray cell". The full-grid dump (3700 reads) 159 was **misread** as "a single stray cell". The full-grid dump (3700 reads)
113 showed `nz=3700 blank=3700` in both grids: **every cell is BLANK (0x70020), 160 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).
153 200
154## OptiPlex flash — what to look for (the linchpin) 201## OptiPlex flash — what to look for (the linchpin)
155 202
203**FIRST OPTIPLEX REPORT (this session, previous image):**
204
205```
206heapdbg: probe@+0x443e0 free=YES w0=203a7476 w1=78302063
207heapdbg: shadow=0x40400000 freed=no
208```
209
210- `w0=0x203a7476 w1=0x78302063` = LE ASCII **"vt: c 0x"** — the leftover
211 bytes of the *vt verify marker String* (`debug_verify`'s
212 `alloc::format!("vt: c {:#x} …")`), which was **allocated at exactly
213 `heap+0x443e0`** (the historical family-A crash ptr) and freed before
214 the marker ran. ⇒ on the Opt the heap's first-fit hands out
215 **`heap+0x443e0` — INSIDE the live shadow span
216 [`heap+0x20`, `heap+0x1D4C20`)** — while `shadow=0x40400000 freed=no`
217 (the shadow Vec's block header claims allocated, forget'd).
218- QEMU control: same marker prints `probe free=false … flhead=0x1b706d0
219 in_shadow=false` — the probe address is NOT in the free list.
220- ⇒ The free list contains the live shadow span on the Opt. The two new
221 boot lines (`fb … in-heap=YES/no`, `flhead … in-shadow=YES/no`) plus
222 the probe's containing `blk=…` tell us whether the overlap is the whole
223 heap block (shadow carve never happened / init_heap after alloc), the
224 fb inside the heap (display clobbers), or a mid-span block.
225
156New image puts on screen (markers are **bottom-anchored** now — they survive 226New image puts on screen (markers are **bottom-anchored** now — they survive
157the banner rows 0–1, the prompt row 2, AND a stale crash.log replay at rows 227the banner rows 0–1, the prompt row 2, AND a stale crash.log replay at rows
1582+; use `gop::bottom_row(off)` to place any future ones): 2282+; use `gop::bottom_row(off)` to place any future ones):
159 229
1601. **Last row (bottom_row(0))**: `gop: shadow {ptr:#x} {MiB}` vs 2301. **Last row (bottom_row(0))**: `heapdbg: probe@+0x443e0 free=YES|no w0=… w1=… blk={base} sz={size}`
161 `gop: NO-SHADOW (fallback)`. 231 — crash offset free? content (zeros=shadow bg, `00aaaaaa`=gray text,
162 - If NO-SHADOW ⇒ shadow-rejection hypothesis gains weight (first block left 232 ASCII=leftover string bytes)? and the **base/size of the free block
163 free + scrub-filled). 233 containing it** (0x40400000 = whole-heap block → the shadow carve
1642. **Second-to-last row (bottom_row(1))**: `vt: c {ptr:#x} cap={cap:#x} ok|GARBAGE | r {ptr:#x} ok|GARBAGE`. 234 never happened; inside the shadow span → overlap; a normal small
165 - **The cells ptr value is the linchpin**: if it equals `0x40444970`, the 235 block → ordinary freed allocation).
166 cells VecData itself is stale at shell entry (matches boot-crash decode). 2362. **Second-to-last row (bottom_row(1))**: `heapdbg: shadow={ptr| -} freed=YES|no`
167 - If cap shows `0xAA…` garbage ⇒ VecData fields scrubbed (bug family B). 237 — the shadow block base + header free flag.
168 - `ok` = all probed cells read BLANK (0x70020); `GARBAGE` = probe ≠ BLANK 2383. **Third-to-last (bottom_row(2))**: `heapdbg: fb={base} size={size} in-heap=YES|no`
169 (the OptiPlex firmware scrub reads back `0x00AAAAAA`). 239 — is the framebuffer inside the heap range (display clobbers heap)?
1703. If it crashes: the full guard band (`hdr:` + all four `mem:` lines, 16 stack 2404. **Fourth-to-last (bottom_row(3))**: `heapdbg: flhead={base} size={size} in-shadow=YES|no`
171 words) plus the bottom markers if still visible. 241 — does the free-list head overlap the shadow span?
172 2425. Also: `vt: c {ptr} cap=… ok|GARBAGE | r {ptr} ok` marker (was
173Serial-side (QEMU only): the verify lines are captured in the klog ring 243 bottom_row(1), now overwritten by the shadow line) — the cells ptr
174(`serial_println!` → ring), so a healthy OptiPlex boot can also dump them with 244 value is the linchpin for a stale-VecData theory: equals `0x40444970`?
175the `klog` shell command. 2456. If it crashes: the full guard band (`hdr:` + all four `mem:` lines, 16
246 stack words) plus the bottom markers if still visible.
247
248Serial-side (QEMU only): the boot heapdbg values are mirrored to serial
249(`heapdbg: probe free=… shadow=… freed=… fb_in_heap=… flhead=… in_shadow=…`)
250and captured in the klog ring — a healthy OptiPlex boot can also dump them
251with the `klog` shell command.
176 252
177## Build/flash/test loop (traps) 253## Build/flash/test loop (traps)
178 254
@@ -194,11 +270,14 @@ the `klog` shell command.
194 270
195## Next steps (priority order) 271## Next steps (priority order)
196 272
1971. **Flash to OptiPlex** and relay the two bottom markers: shadow state (last 2731. **Flash the fixed image to the OptiPlex** (BOOTX64.EFI in `esp/`).
198 row) + vt marker (`bottom_row(1)` — the cells ptr value is the linchpin: 274 Expected bottom rows now: `probe@+0x443e0 free=no` (was YES),
199 equals `0x40444970`? cap scrubbed `0xAA…`? `ok` vs `GARBAGE`?). If crash: 275 `flhead=… in-shadow=no` (was YES), `vt: … ok`. Run the old repro
200 full `hdr:` + all four `mem:` lines. 276 (`klog -t 2`, `ls`, `help`) — should no longer crash.
2012. Reattach the window-manager milestone once the crash family is resolved. 2772. If the Opt still misbehaves: relay the four bottom lines again — the
278 fix only holds if the free list no longer overlaps the shadow.
2793. Reattach the window-manager milestone once confirmed.
202 280
203(QEMU control arm is green: content check now passes on every healthy boot; 281(QEMU control arm is green with and without a forced EBS failure:
204no further QEMU-side investigation is needed.) 282`verify: ok`, probe NOT free, shadow base NOT in free list, full shell
283sequence 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
185 } else { 185 } else {
186 rsnix::serial_println!("gop: not found (0x{:x})", gop_result); 186 rsnix::serial_println!("gop: not found (0x{:x})", gop_result);
187 } 187 }
188 rsnix::gop::boot_marker("rsnix v0.5.5 booting.."); 188 rsnix::gop::boot_marker("rsnix v0.5.9 booting..");
189 rsnix::vt::init(); 189 rsnix::vt::init();
190 let (rows, cols) = rsnix::vt::size(); 190 let (rows, cols) = rsnix::vt::size();
191 rsnix::serial_println!("vt: terminal ready ({}x{})", rows, cols); 191 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
207 } 207 }
208 208
209 // ExitBootServices — we now own the machine 209 // ExitBootServices — we now own the machine
210 if unsafe { (bs.exit_boot_services)(image_handle, map_key) } != EFI_SUCCESS { 210 // DEBUG (OptiPlex sim): corrupt the first key so EBS fails once and
211 // the retry path (capture_memory_map -> init_heap rebuild) runs — the
212 // exact condition that wiped the pre-EBS shadow on real hardware.
213 let ebs_first_key = if rsnix::memory::simulate_ebs_failure() {
214 map_key ^ 1
215 } else {
216 map_key
217 };
218 if unsafe { (bs.exit_boot_services)(image_handle, ebs_first_key) } != EFI_SUCCESS {
211 rsnix::serial_println!("WARN: ExitBootServices failed, retrying..."); 219 rsnix::serial_println!("WARN: ExitBootServices failed, retrying...");
212 match unsafe { rsnix::memory::refresh_and_exit(bs, image_handle) } { 220 match unsafe { rsnix::memory::refresh_and_exit(bs, image_handle) } {
213 Ok(_) => {} 221 Ok(_) => {}
@@ -351,14 +359,10 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut
351 // whether the corruption is already present at shell entry or lands 359 // whether the corruption is already present at shell entry or lands
352 // later, and prints the exact field values when it is. 360 // later, and prints the exact field values when it is.
353 rsnix::vt::debug_verify(); 361 rsnix::vt::debug_verify();
362
354 shell::run(); 363 shell::run();
355} 364}
356 365
357/// Write the OptiPlex's 0x00AAAAAA scrub pattern over the stack region
358/// the shell's frames will reuse, leaving only 256 B of live frame
359/// headroom. 128 KiB covers the whole shell frame chain on QEMU and
360/// the OptiPlex. Best-effort: the region is below rsp inside the boot
361/// stack mapping; the call into `shell::run` overwrites the top word.
362// --------------------------------------------------------------------------- 366// ---------------------------------------------------------------------------
363// Print a memory summary from the stashed memory map 367// Print a memory summary from the stashed memory map
364// --------------------------------------------------------------------------- 368// ---------------------------------------------------------------------------
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) {
242 unsafe { 242 unsafe {
243 FB = Some(fbf); 243 FB = Some(fbf);
244 } 244 }
245 // The RAM backbuffer is the *first* heap allocation. It is allocated
246 // pre-ExitBootServices (before vt::init builds its grids) so it sits at
247 // the front of the free list; the heap free list already excludes the
248 // fb region (init_heap split at the fb hole) and the range-overlap
249 // check below cannot alias the display. An ExitBootServices retry on
250 // real hardware no longer rebuilds the free list (see
251 // memory::refresh_map_key), so this pre-EBS allocation is preserved.
252 shadow_late_init();
253}
245 254
246 // The RAM backbuffer is the *first* heap allocation: allocated here 255/// Allocate the RAM backbuffer as the FIRST heap allocation (called from
247 // (pre-ExitBootServices, before vt::init builds its grids) so no 256/// `init`, pre-ExitBootServices). Degrades to no-shadow mode (never
248 // other heap object can collide with it — the v0.5.3 layout that 257/// panics) when the range would overlap the framebuffer or the alloc fails.
249 // provably boots on real hardware. The heap free list excludes the 258pub fn shadow_late_init() {
250 // fb region (init_heap splits at the fb hole) and we additionally
251 // reject any range overlapping the fb, so the shadow can never
252 // alias the display (an aliased shadow turns every scroll into a
253 // 1.8 MB MMIO→MMIO memcpy on an uncached real fb — a wedge).
254 // Allocation failure degrades to no-shadow mode, never panics.
255 unsafe { 259 unsafe {
256 if let Some(fbinfo) = fb().as_ref() { 260 if let Some(fbinfo) = fb().as_ref() {
257 let words = (fbinfo.pitch * fbinfo.height) / 4; 261 let words = (fbinfo.pitch * fbinfo.height) / 4;
@@ -293,16 +297,6 @@ pub fn init(gop: &mut GraphicsOutputProtocol) {
293 .map(|c| core::str::from_utf8(c).unwrap_or("?")) 297 .map(|c| core::str::from_utf8(c).unwrap_or("?"))
294 .collect(); 298 .collect();
295 dump_lines(&lines); 299 dump_lines(&lines);
296 crate::gop::boot_marker_at("gop: NO-SHADOW (fallback)", bottom_row(0));
297 } else {
298 crate::gop::boot_marker_at(
299 &alloc::format!(
300 "gop: shadow {:#x} {:.2} MiB",
301 SHADOW as usize,
302 swords as f64 * 4.0 / 1048576.0
303 ),
304 bottom_row(0),
305 );
306 } 300 }
307 } 301 }
308 } 302 }
@@ -319,6 +313,25 @@ pub fn boot_marker(msg: &str) {
319 boot_marker_at(msg, 0); 313 boot_marker_at(msg, 0);
320} 314}
321 315
316/// DEBUG (crash-family): read the shadow block's header words.
317/// Returns `(block_base, size, free_flag)` if a shadow is live.
318///
319/// The shadow is the first heap allocation: payload at base+32, header
320/// at base. A live shadow shows `free == 0`; if its block ever leaked
321/// back into the free list (while `SHADOW` still points at it), later
322/// allocations carve into the zeroed span and the display writes keep
323/// zeroing them — the OptiPlex crash family signature.
324pub fn shadow_block_state() -> Option<(usize, usize, usize)> {
325 unsafe {
326 if SHADOW.is_null() {
327 return None;
328 }
329 let base = (SHADOW as usize).wrapping_sub(32);
330 let size = *(base as *const usize);
331 let free = *((base + 8) as *const usize);
332 Some((base, size, free))
333 }
334}
322/// boot_marker at an explicit text row. The shell's first paint 335/// boot_marker at an explicit text row. The shell's first paint
323/// occupies grid rows 0 (motd) and 1 (=== banner) and the prompt sits 336/// occupies grid rows 0 (motd) and 1 (=== banner) and the prompt sits
324/// at row 2 — markers whose *values* matter after boot (shadow state, 337/// 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) -> ! {
624/// .bss working buffer so no pool allocation (and thus no memory-map change) 624/// .bss working buffer so no pool allocation (and thus no memory-map change)
625/// occurs between GetMemoryMap and ExitBootServices. 625/// occurs between GetMemoryMap and ExitBootServices.
626pub fn capture_memory_map(bs: &BootServices) -> Result<usize, Status> { 626pub fn capture_memory_map(bs: &BootServices) -> Result<usize, Status> {
627 capture_memory_map_impl(bs, true)
628}
629
630/// Re-capture the memory map and return a FRESH ExitBootServices key
631/// WITHOUT rebuilding the heap free list. Used by the EBS retry path:
632/// a rebuild from the raw map wipes the allocation state of every
633/// pre-EBS live allocation (the display shadow, the vt grids) — their
634/// spans then become free while still in use, and later allocations
635/// land inside the live shadow (the OptiPlex crash family).
636pub fn refresh_map_key(bs: &BootServices) -> Result<usize, Status> {
637 capture_memory_map_impl(bs, false)
638}
639
640fn capture_memory_map_impl(bs: &BootServices, rebuild: bool) -> Result<usize, Status> {
627 let mut map_key: usize = 0; 641 let mut map_key: usize = 0;
628 let mut desc_size: usize = 0; 642 let mut desc_size: usize = 0;
629 let mut desc_ver: u32 = 0; 643 let mut desc_ver: u32 = 0;
@@ -694,7 +708,9 @@ pub fn capture_memory_map(bs: &BootServices) -> Result<usize, Status> {
694 // Do NOT free anything — the static buffer stays. Map key is still 708 // Do NOT free anything — the static buffer stays. Map key is still
695 // valid because no pool allocation/free happened after GetMemoryMap. 709 // valid because no pool allocation/free happened after GetMemoryMap.
696 710
697 init_heap(); 711 if rebuild {
712 init_heap();
713 }
698 Ok(map_key) 714 Ok(map_key)
699} 715}
700 716
@@ -705,7 +721,13 @@ pub fn capture_memory_map(bs: &BootServices) -> Result<usize, Status> {
705/// `image_handle` must be the valid EFI image handle this kernel was 721/// `image_handle` must be the valid EFI image handle this kernel was
706/// started with (opaque firmware pointer; never dereferenced here). 722/// started with (opaque firmware pointer; never dereferenced here).
707pub unsafe fn refresh_and_exit(bs: &BootServices, image_handle: Handle) -> Result<usize, Status> { 723pub unsafe fn refresh_and_exit(bs: &BootServices, image_handle: Handle) -> Result<usize, Status> {
708 let key = capture_memory_map(bs)?; 724 // Fresh key WITHOUT rebuilding the heap free list: a rebuild from the
725 // raw map wipes the allocation state of every pre-EBS live allocation
726 // (the display shadow, the vt grids) — their spans then become free
727 // while still in use, and later allocations land inside the live
728 // shadow (the OptiPlex crash family: display writes clobber heap
729 // objects, zeroed back-pointers, gray-text pixels).
730 let key = refresh_map_key(bs)?;
709 let status = unsafe { (bs.exit_boot_services)(image_handle, key) }; 731 let status = unsafe { (bs.exit_boot_services)(image_handle, key) };
710 if status == EFI_SUCCESS { 732 if status == EFI_SUCCESS {
711 Ok(key) 733 Ok(key)
@@ -833,6 +855,87 @@ pub fn heap_stats() -> HeapStats {
833 } 855 }
834} 856}
835 857
858/// DEBUG (crash-family): which free block contains `addr`, if any.
859/// Returns `(block_base, block_size, offset_within_block)`.
860///
861/// The OptiPlex heap-corruption dumps land at deterministic offsets
862/// inside the shadow's would-be span; this tells us whether the shadow
863/// block (or any block spanning that offset) has leaked back into the
864/// free list at shell entry or later.
865pub fn freelist_contains(addr: usize) -> Option<(usize, usize, usize)> {
866 unsafe {
867 let mut cur = FREE_LIST;
868 while !cur.is_null() {
869 let base = cur as usize;
870 let size = (*cur).size;
871 if addr >= base && addr < base.saturating_add(size) {
872 return Some((base, size, addr - base));
873 }
874 cur = (*(*cur).links()).next;
875 }
876 None
877 }
878}
879
880/// DEBUG (crash-family): the free list head block (base, size).
881pub fn freelist_head() -> Option<(usize, usize)> {
882 unsafe {
883 if FREE_LIST.is_null() {
884 None
885 } else {
886 Some((FREE_LIST as usize, (*FREE_LIST).size))
887 }
888 }
889}
890
891/// DEBUG (OptiPlex sim): force the first ExitBootServices call to fail,
892/// exercising the capture_memory_map -> init_heap free-list rebuild that
893/// wiped the pre-EBS shadow on real hardware. Returns true when the
894/// simulation is armed (one shot).
895pub static mut SIMULATE_EBS_FAILURE: bool = false;
896pub fn simulate_ebs_failure() -> bool {
897 unsafe {
898 let r = SIMULATE_EBS_FAILURE;
899 SIMULATE_EBS_FAILURE = false;
900 r
901 }
902}
903
904/// DEBUG (crash-family): walk the free list, formatting at most `max`
905/// blocks as text lines for the screen/serial.
906///
907/// Snapshot the walk into a fixed stack array first: printing the lines
908/// itself allocates, and an interleaved alloc/free would shift the list
909/// under the walk (the shell's tty path allocates per line).
910pub fn freelist_lines(max: usize) -> alloc::vec::Vec<alloc::string::String> {
911 let mut out = alloc::vec::Vec::new();
912 unsafe {
913 let mut cur = FREE_LIST;
914 let cap = max.min(24);
915 let mut snap: [(usize, usize); 24] = [(0, 0); 24];
916 let mut seen = 0usize;
917 while !cur.is_null() && seen < cap {
918 snap[seen] = (cur as usize, (*cur).size);
919 seen += 1;
920 cur = (*(*cur).links()).next;
921 }
922 if seen == 0 {
923 out.push("fl[] EMPTY".into());
924 } else {
925 for (base, size) in snap[..seen].iter() {
926 out.push(alloc::format!(
927 "fl[{}] {:#x} size={:#x} end={:#x}",
928 out.len(),
929 base,
930 size,
931 base.wrapping_add(*size)
932 ));
933 }
934 }
935 }
936 out
937}
938
836/// Allocator smoke test — exercises alloc/free/coalesce without touching 939/// Allocator smoke test — exercises alloc/free/coalesce without touching
837/// the heap itself (fixed-size stack arrays only). Returns true on success. 940/// the heap itself (fixed-size stack arrays only). Returns true on success.
838pub fn self_test() -> bool { 941pub 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) {
138 unsafe { (ROWS, COLS) } 138 unsafe { (ROWS, COLS) }
139} 139}
140 140
141/// DEBUG (crash-family): read one cell's raw word (ch | fg<<16 | bg<<24)
142/// from the live cells grid and the rendered grid, for the given text
143/// row/col. Returns (raw_cell, raw_rendered).
144pub fn cell_raw(r: usize, c: usize) -> (u32, u32) {
145 unsafe {
146 if CELLS.is_null() || RENDERED.is_null() {
147 return (0xFFFF_FFFF, 0xFFFF_FFFF);
148 }
149 let raw = |v: &Vec<Cell>| -> u32 {
150 if r < ROWS && c < COLS {
151 let cell = v[r * COLS + c];
152 cell.ch as u32 | ((cell.fg as u32) << 16) | ((cell.bg as u32) << 24)
153 } else {
154 0xFFFF_FFFE
155 }
156 };
157 (
158 raw(&*(CELLS as *const Vec<Cell>)),
159 raw(&*(RENDERED as *const Vec<Cell>)),
160 )
161 }
162}
163
141/// Shell-entry self-check: dump the vt grid's heap structs so a real- 164/// Shell-entry self-check: dump the vt grid's heap structs so a real-
142/// hardware boot shows whether the cells VecData / data arrays are sane 165/// hardware boot shows whether the cells VecData / data arrays are sane
143/// at shell entry (the OptiPlex's first banner write #GPs when the 166/// at shell entry (the OptiPlex's first banner write #GPs when the
@@ -184,9 +207,12 @@ pub fn debug_verify() {
184 // other three probe slots as zero-initialized). A stale cells 207 // other three probe slots as zero-initialized). A stale cells
185 // data pointer that happens to be in-heap still passes the 208 // data pointer that happens to be in-heap still passes the
186 // range checks (len/cap look fine) but its "cells" read back as 209 // range checks (len/cap look fine) but its "cells" read back as
187 // the underlying memory's leftover fill — the OptiPlex shows 210 // the underlying memory's leftover fill. NOTE: the OptiPlex
188 // the firmware 0x00AAAAAA scrub there. BLANK (0x70020) vs 211 // dump value 0x00AAAAAA is the VGA GRAY text colour
189 // scrub (0x00AAAAAA) is cleanly distinguishable. 212 // (rgb(170,170,170) = PALETTE[7] = fg 7), i.e. rendered shadow
213 // pixels — the memory read as heap held display text, not a
214 // firmware scrub. BLANK (0x70020) vs 0x00AAAAAA is cleanly
215 // distinguishable.
190 let blank_raw = BLANK.ch as u32 | ((BLANK.fg as u32) << 16) | ((BLANK.bg as u32) << 24); 216 let blank_raw = BLANK.ch as u32 | ((BLANK.fg as u32) << 16) | ((BLANK.bg as u32) << 24);
191 let mut content_ok = true; 217 let mut content_ok = true;
192 let mut rcontent_ok = true; 218 let mut rcontent_ok = true;
@@ -269,31 +295,24 @@ pub fn debug_verify() {
269 r_probes_raw[2], 295 r_probes_raw[2],
270 r_probes_raw[3] 296 r_probes_raw[3]
271 )); 297 ));
272 // Bottom-anchored: row 0-1 get overwritten by the shell 298 // NOTE: debug markers are serial-only now; the crash family
273 // banner, row 2 by the prompt (and a stale crash.log 299 // was resolved (EBS-retry free-list rebuild) and the screen
274 // replays at rows 2+), so this marker must live at the very 300 // must stay clean for users.
275 // bottom to stay readable until the user can copy it. 301 crate::serial_println!(
276 crate::gop::boot_marker_at( 302 "vt: c {:#x} cap={:#x} {} | r {:#x} {}",
277 &alloc::format!( 303 c.as_ptr() as usize,
278 "vt: c {:#x} cap={:#x} {} | r {:#x} {}", 304 c.capacity(),
279 c.as_ptr() as usize, 305 if content_ok { "ok" } else { "GARBAGE" },
280 c.capacity(), 306 r.as_ptr() as usize,
281 if content_ok { "ok" } else { "GARBAGE" }, 307 if rcontent_ok { "ok" } else { "GARBAGE" }
282 r.as_ptr() as usize,
283 if rcontent_ok { "ok" } else { "GARBAGE" }
284 ),
285 crate::gop::bottom_row(1),
286 ); 308 );
287 } else { 309 } else {
288 msg("verify: ok"); 310 msg("verify: ok");
289 crate::gop::boot_marker_at( 311 crate::serial_println!(
290 &alloc::format!( 312 "vt: c {:#x} cap={:#x} ok | r {:#x} ok",
291 "vt: c {:#x} cap={:#x} ok | r {:#x} ok", 313 c.as_ptr() as usize,
292 c.as_ptr() as usize, 314 c.capacity(),
293 c.capacity(), 315 r.as_ptr() as usize
294 r.as_ptr() as usize
295 ),
296 crate::gop::bottom_row(1),
297 ); 316 );
298 } 317 }
299 } 318 }
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 `{} <path> TIL <marker>`",
716 "hello" => cmd_hello(&parsed), 716 "hello" => cmd_hello(&parsed),
717 "info" => cmd_info(&parsed), 717 "info" => cmd_info(&parsed),
718 "mem" => cmd_mem(&parsed), 718 "mem" => cmd_mem(&parsed),
719 "heapdbg" => cmd_heapdbg(&parsed),
719 "mmap" => cmd_mmap(&parsed), 720 "mmap" => cmd_mmap(&parsed),
720 "stress" => cmd_stress(&parsed), 721 "stress" => cmd_stress(&parsed),
721 "date" => cmd_date(&parsed), 722 "date" => cmd_date(&parsed),
@@ -1571,6 +1572,134 @@ fn cmd_halt() -> ! {
1571 rsnix::power::halt() 1572 rsnix::power::halt()
1572} 1573}
1573 1574
1575/// Debug the crash-family: free-list state + the historical crash
1576/// offset (heap+0x443e0) + the shadow block's header. On the OptiPlex
1577/// this shows whether the shadow span leaked into the free list and
1578/// whether the probe region is zeroed/0xAA before any command runs.
1579fn cmd_heapdbg(_p: &Parsed) {
1580 let h = rsnix::memory::heap_stats();
1581 term_println(&alloc::format!(
1582 "heap: {:#x}..{:#x} cap={} MiB free={} KiB blocks={}",
1583 h.heap_start,
1584 h.heap_end,
1585 h.heap_size / 1048576,
1586 h.free_bytes / 1024,
1587 h.free_blocks
1588 ));
1589 for l in rsnix::memory::freelist_lines(12) {
1590 term_println(&l);
1591 }
1592 // fb vs heap overlap — OptiPlex crash hypothesis: the display's
1593 // framebuffer may sit inside the heap region (GPU BAR not reserved in
1594 // the EFI map), so display writes clobber live heap allocations.
1595 if let Some((fb, fsz)) = rsnix::gop::framebuffer_region() {
1596 let ov = h.heap_start < fb.saturating_add(fsz) && fb < h.heap_end;
1597 term_println(&alloc::format!(
1598 "fb {:#x} size={:#x} overlap-heap={}",
1599 fb, fsz, ov
1600 ));
1601 }
1602 let probe = h.heap_start.wrapping_add(0x443e0); // historical crash offset
1603 match rsnix::memory::freelist_contains(probe) {
1604 Some((b, s, off)) => term_println(&alloc::format!(
1605 "probe {:#x} (+0x443e0) IN free blk {:#x} size={:#x} off={:#x}",
1606 probe, b, s, off
1607 )),
1608 None => term_println(&alloc::format!("probe {:#x} (+0x443e0) NOT free", probe)),
1609 }
1610 let mut s = alloc::string::String::new();
1611 unsafe {
1612 for i in 0..8 {
1613 if i > 0 {
1614 s.push(' ');
1615 }
1616 let v = *(probe as *const u32).add(i);
1617 s.push_str(&alloc::format!("{:08x}", v));
1618 }
1619 }
1620 term_println(&alloc::format!("probe mem: {}", s));
1621 // Scan the live shadow payload for 0x00aaaaaa words. NOTE: this is
1622 // the GRAY text color (rgb(170,170,170) = PALETTE[7] = fg 7), NOT a
1623 // firmware scrub — the "0x00AAAAAA fill" in the OptiPlex crash dumps
1624 // is just rendered text pixels. The scan's real value: untouched
1625 // shadow rows must be 0; non-zero far rows = out-of-bounds writes.
1626 if let Some((b, size, _)) = rsnix::gop::shadow_block_state() {
1627 let pay = b + 32;
1628 let words = (size.saturating_sub(32)) / 4;
1629 let mut n = 0u32;
1630 let mut first: [usize; 8] = [0; 8];
1631 let mut nf = 0usize;
1632 unsafe {
1633 let mut i = 0usize;
1634 let cap = words.min(240_000); // bound the scan to avoid stalls
1635 while i < cap {
1636 let v = *(pay as *const u32).add(i);
1637 if v == 0x00aa_aaaa {
1638 if nf < first.len() {
1639 first[nf] = i;
1640 }
1641 nf += 1;
1642 n += 1;
1643 }
1644 i += 1;
1645 }
1646 }
1647 let mut fs = alloc::string::String::new();
1648 for &f in first[..nf.min(8)].iter() {
1649 fs.push_str(&alloc::format!("@{}(r{}c{}) ", f, f / 800, (f % 800) / 8));
1650 }
1651 term_println(&alloc::format!(
1652 "shadow scan: {}w/{} ({}%) {}",
1653 n,
1654 words,
1655 (n * 1000 / (words as u32).max(1)) / 10,
1656 fs
1657 ));
1658 // Probe far rows (untouched by text) to check the init zeroing.
1659 let mut d = alloc::string::String::new();
1660 unsafe {
1661 for (label, off) in [("r100", 80_000usize), ("r300", 240_000), ("r500", 400_000)] {
1662 let w0 = *(pay as *const u32).add(off);
1663 let w1 = *(pay as *const u32).add(off + 1);
1664 d.push_str(&alloc::format!(" {}={:08x}{:08x}", label, w0, w1));
1665 }
1666 }
1667 term_println(&alloc::format!("shadow far: {}", d));
1668 // Underlying cell/rendered values at the first 0x00aaaaaa (gray
1669 // text pixel) hit, if any: confirms the shadow word is the glyph
1670 // pixel of a fg=7 cell (consistent) vs a shadow write outside
1671 // the vt path (inconsistent).
1672 if nf > 0 {
1673 let f = first[0];
1674 let tr = f / 800 / 16;
1675 let tc = (f / 8) % 100;
1676 let (rc, rr) = rsnix::vt::cell_raw(tr, tc);
1677 term_println(&alloc::format!(
1678 "shadow@{:x} cell={:08x} rendered={:08x}",
1679 f, rc, rr
1680 ));
1681 }
1682 }
1683 match rsnix::gop::shadow_block_state() {
1684 Some((b, size, free)) => {
1685 term_println(&alloc::format!(
1686 "shadow blk {:#x} size={:#x} free={}",
1687 b,
1688 size,
1689 free
1690 ));
1691 match rsnix::memory::freelist_contains(b) {
1692 Some((fb, fs, fo)) => term_println(&alloc::format!(
1693 "shadow base IN free list: blk {:#x} size={:#x} off={:#x}",
1694 fb, fs, fo
1695 )),
1696 None => term_println("shadow base NOT in free list"),
1697 }
1698 }
1699 None => term_println("shadow: NONE (no-shadow mode)"),
1700 }
1701}
1702
1574fn cmd_date(p: &Parsed) { 1703fn cmd_date(p: &Parsed) {
1575 let t = rsnix::rtc::now(); 1704 let t = rsnix::rtc::now();
1576 let mut pipe = Pipeline::new(&p.filters, &p.output); 1705 let mut pipe = Pipeline::new(&p.filters, &p.output);