From 698a5d5f046c512f1b2b0c5535919025832fb0bd Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Fri, 5 Jun 2026 08:21:34 -0400 Subject: [PATCH] asm: gc_sweep page-fault on chunk-abandonment gap (segfault at chunk_end) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When heap_alloc walks off the end of a chunk with < 16 tail bytes, .ha_grow_no_pad skips padding and mmaps a fresh chunk — but gc_chunk_end[N] for the abandoned chunk stayed at its full mmap end while %r15 (high-water) sat 1..15 bytes short. The gap held mmap-zeros that gc_sweep's .gsw_walk decoded as fake dead blocks (header == 0, payload size == 0, mark == 0). The walker stepped through the zeros 8 bytes at a time, and on the iteration where %rbx == chunk_end - 8 the .gsw_dead path stored the free-list next-pointer to 0x8(%rbx) == chunk_end — the first byte of an unmapped page — and segfaulted with error 7. Reproducer (inside QEMU guest, was crashing all three asm binaries): lumbda-full tests/unit/test-mod-solinas.lsp → segfault at ip:4017d9 (.gsw_dead: mov %rdx, 0x8(%rbx)) Fix: at .ha_grow_no_pad, snapshot %r15 into gc_chunk_end[N] before allocating the new chunk. In the padded path above the label this is a no-op (r15 already == r13). In the un-padded path it pins the walk bound to the high-water mark so the sweep never enters the gap. After fix, on lumbda-full inside the ecdsa QEMU guest: - ecdsa test-mod-solinas: 39/39 PASS (Solinas vs Litinski byte-equal at p ∈ {11, 13, 251}) - upstream asm test.sh: 158/158 PASS Discovered while diagnosing ecdsa task #45. --- asm/lumbda.s | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/asm/lumbda.s b/asm/lumbda.s index c0e68ca..4080f4e 100644 --- a/asm/lumbda.s +++ b/asm/lumbda.s @@ -1044,6 +1044,20 @@ heap_alloc: movq %r15, gc_free_list(%rip) addq %rax, %r15 # %r15 now == %r13 .ha_grow_no_pad: + # Pin the abandoned chunk's stored end to %r15 (its high-water). + # In the padded path above this is a no-op (r15 already == r13). + # In the un-padded path (tail < 16) r15 sits short of r13 and the + # gap holds mmap-zeros. Without this update, gc_sweep would still + # walk to the original mmap end, decode the zero pages as fake + # dead blocks, and on the final iteration write the free-list + # next-pointer to (chunk_end - 8) + 8 == chunk_end — the first + # byte of an unmapped page — and segfault in .gsw_dead. Tying + # chunk_end to r15 stops the walker at the high-water mark. + # Discovered while diagnosing ecdsa task #45, 2026-06-05. + movq gc_chunk_count(%rip), %rdx + decq %rdx + leaq gc_chunk_end(%rip), %rcx + movq %r15, (%rcx,%rdx,8) # Chunk size: at least HEAP_SIZE, but large enough for this one # block. A single big allocation (e.g. file->string on a ~3 MB # PDF) would otherwise loop forever because each mmap'd 1 MB