asm-gc Fix 1: precise block typing kills conservative-scan class of bugs

Replaces header format from [size:63 | mark:1] with
[size:48 | type:8 | flags:8 (mark in bit 0)]. Every heap_alloc
call site in the GC build now sets its type byte via one extra
`orq $(HT_X << 8), -8(%rax)` after return. Ten types defined:
HT_PAIR, HT_CLOSURE, HT_STRING, HT_SYMBOL, HT_VECTOR,
HT_HASHTABLE, HT_HASHSET, HT_ENVNODE, HT_CHAINNODE, HT_PADDING.

The mark / sweep / arena-escape walkers now dispatch on the
type byte instead of heuristically guessing from block size.
Deletes the special-case "negative sentinel at offset 0" branch
in gc_mark_drain (hash-table vs hash-set vs vector discrimination
was encoded there), the "size == 24 and TAG_SYM at offset 0"
check in gc_mark_env, and the "length fits block" sanity check
in the vector walker. All that logic collapses into a single
compare on the type byte.

Also routed the remaining direct-%r15-bump allocators
(bi_strref, bi_vector, bi_makevec, bi_listtovec, bi_substr)
through heap_alloc so they get proper headers + type bytes.
These had been silently broken under the GC build because they
bypassed the header-emitting path entirely; any direct-bump'd
data appeared to the sweep walker as garbage headers.

§6.6.4 cell 4 (asm GC + no snapshot) was crashing at first GC
before this change. After: serves 5,000 HTTP requests at ~410
req/s, peak RSS 1,088 KB (one chunk), growth 972 KB — the
collector hit its natural steady state. First time we've
validated "naive GC as replacement for snapshot discipline"
under real traffic.

New §6.6.5 "Precise Block Typing" in the whitepaper documents
the old heuristic bugs, the new header format, and the cost
(one orq per alloc, 16 header bits) vs benefit (class of bugs
eliminated). Updated §6.6.4 to reflect cell 4 passing.

Remaining known issue: the hash-set bench on the GC build under
very heavy sustained allocation still surfaces an occasional
unbound-variable error. The precise-type fix addressed the
observed HTTP crash; a deeper root-scan edge case remains.
Tracked for Fix 2 work.

137 asm no-GC + 137 asm GC + 189 shared functional tests all
pass.
This commit is contained in:
russell@unturf.com 2026-04-18 19:33:44 -04:00
parent 3348e9b4bd
commit 5ec9eff5fe
7 changed files with 953 additions and 850 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -450,8 +450,29 @@ sym_hash_buckets: .skip 8192 # 1024 * 8 bytes
.ifdef GC_NAIVE
#
# Naive mark-and-sweep GC state (control group for bench).
# Every heap block carries an 8-byte header: (size << 1) | mark.
# Every heap block carries an 8-byte header:
# bit 0 = mark
# bits 17 = reserved
# bits 815 = type byte (HT_PAIR, HT_CLOSURE, )
# bits 1663 = payload size in bytes
# The tagged pointer still points at the payload (header at -8).
# The type byte lets mark / sweep / arena walkers dispatch
# precisely instead of guessing from block size earlier versions
# had to reject 24-byte strings-masquerading-as-env-nodes via
# TAG_SYM checks at offset 0, and 40-byte strings masquerading
# as vectors via a length-fits-block check. Type byte kills the
# entire class of bugs.
.equ HT_FREE, 0 # not used during normal ops (0 = unknown / free)
.equ HT_PAIR, 1
.equ HT_CLOSURE, 2
.equ HT_STRING, 3
.equ HT_SYMBOL, 4
.equ HT_VECTOR, 5
.equ HT_HASHTABLE, 6
.equ HT_HASHSET, 7
.equ HT_ENVNODE, 8
.equ HT_CHAINNODE, 9
.equ HT_PADDING, 10
# Chunks are tracked in a side array so chunk memory stays pure
# allocation space. Free blocks are linked via a global list
# whose nodes reuse the header word as size and the first 8 bytes
@ -692,7 +713,7 @@ heap_alloc:
cmpq %r13, %rcx
ja .ha_overflow
movq %rbx, %rdx
shlq $1, %rdx # header = size << 1, mark=0
shlq $16, %rdx # header = (size << 16) | (type<<8) | mark; caller patches type
movq %rdx, (%r15)
movq %r15, %rax
addq $8, %rax # payload ptr
@ -725,7 +746,7 @@ heap_alloc:
cmpq %r13, %rcx
ja .ha_grow
movq %rbx, %rdx
shlq $1, %rdx
shlq $16, %rdx
movq %rdx, (%r15)
movq %r15, %rax
addq $8, %rax
@ -744,7 +765,8 @@ heap_alloc:
movq %rax, %rdx
subq $8, %rdx # padding payload size
movq %rdx, %rcx
shlq $1, %rcx # header, mark=0
shlq $16, %rcx # header; type will be patched to HT_PADDING below
orq $(HT_PADDING << 8), %rcx
movq %rcx, (%r15)
# Link onto free list directly so sweep doesn't need to know.
movq gc_free_list(%rip), %rcx
@ -785,7 +807,7 @@ gc_freelist_alloc:
testq %rax, %rax
jz .gfa_empty
movq (%rax), %rdx # header
shrq $1, %rdx # block payload size
shrq $16, %rdx # block payload size (bits 16..63)
cmpq %rdi, %rdx
jb .gfa_next
# Fits. Unlink.
@ -807,14 +829,17 @@ gc_freelist_alloc:
leaq 8(%rax,%rdi), %r9 # tail header addr
subq $8, %r8
movq %r8, %r10
shlq $1, %r10 # mark=0
shlq $16, %r10 # tail header, type=0 (free), mark=0
movq %r10, (%r9)
movq gc_free_list(%rip), %r11
movq %r11, 8(%r9)
movq %r9, gc_free_list(%rip)
# Shrink current block's header.
# Shrink current block's header keep its type byte, just resize.
movq (%rax), %r10 # old header
andq $0xff00, %r10 # keep type byte; drop old size+mark
movq %rdi, %rdx
shlq $1, %rdx
shlq $16, %rdx
orq %r10, %rdx # new header: new size + old type
movq %rdx, (%rax)
.gfa_return_whole:
addq $8, %rax # payload ptr
@ -1011,19 +1036,11 @@ gc_mark_env:
popq %rdi
testq %rcx, %rcx
jz .gme_end
# Block's header payload-size must be 24 AND offset 0 must be
# a tagged symbol. Env nodes are (sym, val, parent); strings
# and closures are also 24 bytes when they happen to land at
# that size, so a bare size check treats them as env and the
# walker corrupts the heap by reading string bytes as env
# fields. Demanding TAG_SYM at offset 0 disambiguates.
movq -8(%rdi), %rax
shrq $1, %rax
cmpq $24, %rax
jne .gme_end
movq (%rdi), %rax
andq $7, %rax
cmpq $TAG_SYM, %rax
# Precise type dispatch: we want HT_ENVNODE exactly. The old
# heuristic (size==24 + offset-0 is TAG_SYM) is redundant once
# the type byte is populated, but still accepts the same set.
movzbq -7(%rdi), %rax # header byte 1 (type byte)
cmpq $HT_ENVNODE, %rax
jne .gme_end
# Already marked? Skip.
testq $1, -8(%rdi)
@ -1113,7 +1130,10 @@ gc_push_if_heap:
ret
# gc_mark_drain: pop tagged values from mark stack, set header
# mark, push children based on tag. Loop until empty.
# mark, push children based on the TYPE BYTE in the header (bits
# 8..15). Dispatching by type instead of by tagged-value tag
# eliminates the class of bugs where a tag-7 value (stack scan
# false positive) had to be disambiguated by guessing from size.
gc_mark_drain:
.gmd_top:
movq gc_mark_depth(%rip), %rcx
@ -1122,23 +1142,24 @@ gc_mark_drain:
decq %rcx
movq %rcx, gc_mark_depth(%rip)
leaq gc_mark_stack(%rip), %rsi
movq (%rsi,%rcx,8), %rbx # tagged value (callee-save keeps it safe)
movq (%rsi,%rcx,8), %rbx # tagged value
movq %rbx, %rsi
andq $-8, %rsi # untagged ptr
# If already marked, skip.
testq $1, -8(%rsi)
jnz .gmd_top
orq $1, -8(%rsi) # mark header
# Dispatch by tag.
movq %rbx, %rax
andq $TAG_MASK, %rax
cmpq $TAG_PAIR, %rax
jnz .gmd_top # already marked
orq $1, -8(%rsi) # mark
movzbq -7(%rsi), %rax # type byte
cmpq $HT_PAIR, %rax
je .gmd_pair
cmpq $TAG_CLOSURE, %rax
cmpq $HT_CLOSURE, %rax
je .gmd_closure
cmpq $7, %rax
je .gmd_vec7
# STRING / SYM: no children.
cmpq $HT_VECTOR, %rax
je .gmd_vector
cmpq $HT_HASHTABLE, %rax
je .gmd_hash
cmpq $HT_HASHSET, %rax
je .gmd_hash
# HT_STRING / HT_SYMBOL / HT_CHAINNODE / HT_PADDING / HT_FREE: no children
jmp .gmd_top
.gmd_pair:
movq (%rsi), %rdi
@ -1161,29 +1182,10 @@ gc_mark_drain:
movq 16(%rsi), %rdi
call gc_mark_env # env field is an untagged env chain
jmp .gmd_top
.gmd_vec7:
# Block size from header. The conservative stack scan can push
# any value whose low 3 bits happen to equal 7; that untagged
# "pointer" might land in a chunk but belong to a string or
# closure, not an actual vector. Validate size against the
# claimed length/layout before walking otherwise a 40-byte
# string misinterpreted as a 25-element vector reads 200 bytes
# past itself and corrupts marks on adjacent blocks.
movq -8(%rsi), %rax
shrq $1, %rax # block payload size
movq (%rsi), %rdx # first word: length or -1 or -2
cmpq $-2, %rdx
je .gmd_ht_check
cmpq $-1, %rdx
je .gmd_ht_check
testq %rdx, %rdx
js .gmd_top # other negatives = not ours
# Vector: length must fit: 8 + length*8 <= block_size
movq %rdx, %rcx
shlq $3, %rcx
addq $8, %rcx
cmpq %rax, %rcx
ja .gmd_top # length too large for block
.gmd_vector:
# Vector: first word = length, elements at offset 8.
# Type byte already confirmed it's a real vector, no size guesswork.
movq (%rsi), %rdx # length
xorq %r8, %r8
.gmd_vec_loop:
cmpq %rdx, %r8
@ -1199,19 +1201,9 @@ gc_mark_drain:
incq %r8
jmp .gmd_vec_loop
.gmd_ht_check:
# Hash-table (-1) or hash-set (-2): require block size >= 24
# (header) + nbuckets * 8. nbuckets at offset 16.
cmpq $24, %rax
jb .gmd_top
.gmd_hash:
# Hash-table or hash-set: nbuckets at offset 16, buckets at 24+.
movq 16(%rsi), %rcx # nbuckets
testq %rcx, %rcx
js .gmd_top # garbage
movq %rcx, %rdx
shlq $3, %rdx
addq $24, %rdx
cmpq %rax, %rdx
ja .gmd_top # nbuckets too large
xorq %r8, %r8
.gmd_ht_loop:
cmpq %rcx, %r8
@ -1257,7 +1249,7 @@ gc_sweep:
jae .gsw_next_chunk
movq (%rbx), %rsi # header
movq %rsi, %r11
shrq $1, %r11 # payload size
shrq $16, %r11 # payload size (bits 16..63)
testq $1, %rsi
jz .gsw_dead
# Live: clear mark.
@ -1334,6 +1326,9 @@ make_pair:
call heap_alloc
popq %rsi
popq %rdi
.ifdef GC_NAIVE
orq $(HT_PAIR << 8), -8(%rax)
.endif
movq %rdi, (%rax)
movq %rsi, 8(%rax)
orq $TAG_PAIR, %rax
@ -1349,6 +1344,9 @@ make_closure:
popq %rdx
popq %rsi
popq %rdi
.ifdef GC_NAIVE
orq $(HT_CLOSURE << 8), -8(%rax)
.endif
movq %rdi, (%rax) # params
movq %rsi, 8(%rax) # body
movq %rdx, 16(%rax) # env
@ -1376,6 +1374,9 @@ env_define:
popq %rdx
popq %rsi
popq %rdi
.ifdef GC_NAIVE
orq $(HT_ENVNODE << 8), -8(%rax)
.endif
movq %rdi, (%rax)
movq %rsi, 8(%rax)
movq %rdx, 16(%rax)
@ -1503,6 +1504,9 @@ intern_symbol:
# Use the stack to save sym_ptr across the second heap_alloc.
leaq 1(%r12), %rdi
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_SYMBOL << 8), -8(%rax)
.endif
# %rax = sym_ptr; fill symbol: length byte + chars
movb %r12b, (%rax)
xorq %r8, %r8
@ -1524,6 +1528,9 @@ intern_symbol:
# Allocate hash chain node: 16 bytes [sym_ptr, next_ptr]
movq $16, %rdi
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_CHAINNODE << 8), -8(%rax)
.endif
# Fill chain node: %rax = node_ptr, stack top = sym_ptr
popq %rcx # rcx = sym_ptr
movq %rcx, (%rax) # node->sym = sym_ptr
@ -1879,6 +1886,9 @@ scheme_read:
pushq %rbx
leaq 8(%rbx), %rdi
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_STRING << 8), -8(%rax)
.endif
popq %rbx
movq %rbx, (%rax) # 8-byte length
xorq %rcx, %rcx
@ -4279,12 +4289,18 @@ bi_strref:
movq %rax, %rcx # string ptr
GETARG %rax # index
sarq $3, %rax
movzbl 8(%rcx,%rax,1), %edi # byte at offset
# Return as 1-char string
movq %r15, %rax
movq $1, (%r15) # length
movb %dil, 8(%r15)
addq $16, %r15
movzbl 8(%rcx,%rax,1), %r8d # byte at offset (keep across heap_alloc call)
# Allocate a 1-char string via heap_alloc so the GC build sees
# a proper header / type byte instead of a headerless direct bump.
movq $9, %rdi # 8B length + 1B char
pushq %r8
call heap_alloc
popq %r8
.ifdef GC_NAIVE
orq $(HT_STRING << 8), -8(%rax)
.endif
movq $1, (%rax) # length
movb %r8b, 8(%rax)
orq $TAG_STRING, %rax
RET_VAL
@ -4313,6 +4329,9 @@ bi_strappend:
movq %rcx, %rdi
addq $8, %rdi
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_STRING << 8), -8(%rax)
.endif
movq %rax, %rbp # string object base
movq %rbx, (%rbp) # store length
leaq 8(%rbp), %r9 # dest cursor
@ -4406,6 +4425,9 @@ bi_numtostr:
movq %rcx, %rdi
addq $8, %rdi
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_STRING << 8), -8(%rax)
.endif
popq %rcx
popq %rdi
movq %rcx, (%rax) # length
@ -4601,13 +4623,20 @@ bi_vector:
movq 8(%rdx), %rax
jmp .bvec_count
.bvec_alloc:
# Allocate: 8 bytes length + 8*count bytes
movq %r15, %rax # vector obj
movq %rcx, (%r15) # length
leaq 8(%r15,%rcx,8), %r15
# Allocate via heap_alloc so GC build gets a header + type byte.
pushq %rdi # save arg list ptr
pushq %rcx # save count
leaq 8(,%rcx,8), %rdi # 8 (length) + count * 8
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_VECTOR << 8), -8(%rax)
.endif
popq %rcx
popq %rdi # restore arg list
movq %rcx, (%rax) # length
# Fill elements from arg list
movq %rdi, %rdx # arg list
leaq 8(%rax), %rdi # elements start
movq %rdi, %rdx # arg list
leaq 8(%rax), %rdi # elements start
.bvec_fill:
cmpq $VAL_NIL, %rdx
je .bvec_done2
@ -4627,15 +4656,21 @@ bi_vector:
bi_makevec:
# (make-vector n fill) allocate n-slot vector filled with `fill`.
# GETARG uses %rax as scratch, so we MUST stash the length in a
# callee-safe register before consuming the second arg.
GETARG %rax
sarq $3, %rax # n (untagged)
movq %rax, %rdx # save n in %rdx survives next GETARG
GETARG %rcx # fill value (tagged)
movq %r15, %rax # vector obj pointer
movq %rdx, (%r15) # store length
leaq 8(%r15,%rdx,8), %r15 # advance heap past length + n*8 bytes
# Route through heap_alloc so GC build gets header + type.
pushq %rdx # n
pushq %rcx # fill value
leaq 8(,%rdx,8), %rdi # bytes: 8 (length) + n*8
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_VECTOR << 8), -8(%rax)
.endif
popq %rcx # fill
popq %rdx # n
movq %rdx, (%rax) # store length
leaq 8(%rax), %rdi # elements start
movq %rdx, %rsi # count = n
.bmv_fill:
@ -4725,11 +4760,18 @@ bi_listtovec:
movq 8(%rdx), %rax
jmp .bl2v_count
.bl2v_alloc:
movq %r15, %rax
movq %rcx, (%r15)
leaq 8(%r15,%rcx,8), %r15
leaq 8(%rax), %rdi
movq %rsi, %rdx
pushq %rsi # save list ptr
pushq %rcx # save count
leaq 8(,%rcx,8), %rdi # 8 + count*8
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_VECTOR << 8), -8(%rax)
.endif
popq %rcx
popq %rsi
movq %rcx, (%rax) # length
leaq 8(%rax), %rdi # element cursor
movq %rsi, %rdx # list cursor
.bl2v_fill:
cmpq $VAL_NIL, %rdx
je .bl2v_done
@ -4755,11 +4797,24 @@ bi_substr:
GETARG %rax # end
sarq $3, %rax
subq %rcx, %rax # length = end - start
movq %r15, %rdx # result
movq %rax, (%r15) # length
leaq 8(%r15), %rsi # dest
leaq 8(%rdi,%rcx,1), %rdi # src
movq %rax, %rcx
# Allocate via heap_alloc: 8 (length word) + length bytes.
pushq %rdi # src base
pushq %rcx # start offset
pushq %rax # length
movq %rax, %rdi
addq $8, %rdi # total payload
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_STRING << 8), -8(%rax)
.endif
popq %rdx # length (restore)
popq %rcx # start
popq %rdi # src base
movq %rdx, (%rax) # write length
leaq 8(%rax), %rsi # dest cursor
leaq 8(%rdi,%rcx,1), %rdi # src cursor
movq %rdx, %rcx # bytes to copy
movq %rax, %rdx # save result base
.bsub_copy:
testq %rcx, %rcx
jz .bsub_done
@ -4770,11 +4825,6 @@ bi_substr:
decq %rcx
jmp .bsub_copy
.bsub_done:
movq (%rdx), %rax # length
addq $8, %rax
addq $7, %rax
andq $-8, %rax
addq %rax, %r15
movq %rdx, %rax
orq $TAG_STRING, %rax
RET_VAL
@ -5296,6 +5346,9 @@ bi_file_to_string:
movq %rbp, %rdi
addq $8, %rdi
call heap_alloc # %rax = ptr
.ifdef GC_NAIVE
orq $(HT_STRING << 8), -8(%rax)
.endif
movq %rax, %r12 # save heap pointer
movq %rbp, (%r12) # store length
@ -5430,6 +5483,9 @@ bi_symbol_to_string:
movq %rcx, %rdi
addq $8, %rdi # cell size: 8-byte length + bytes
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_STRING << 8), -8(%rax)
.endif
popq %r8
popq %rcx
@ -5548,6 +5604,9 @@ ht_chain_find:
bi_make_hash_table:
movq $HT_TOTAL_BYTES, %rdi
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_HASHTABLE << 8), -8(%rax)
.endif
movq $HT_SENTINEL, (%rax)
movq $0, 8(%rax) # count
movq $HT_NBUCKETS, 16(%rax)
@ -5928,6 +5987,9 @@ hs_chain_find:
bi_make_hash_set:
movq $HT_TOTAL_BYTES, %rdi
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_HASHSET << 8), -8(%rax)
.endif
movq $HS_SENTINEL, (%rax)
movq $0, 8(%rax)
movq $HT_NBUCKETS, 16(%rax)
@ -6331,7 +6393,7 @@ arena_verify_and_commit:
movq (%rbx), %rax
testq $1, %rax
jnz .avc_escape
shrq $1, %rax
shrq $16, %rax
leaq 8(%rbx,%rax), %rbx
jmp .avc_scan
.avc_safe:
@ -6626,6 +6688,9 @@ bi_tcp_recv:
movq %rbp, %rdi
addq $8, %rdi
call heap_alloc
.ifdef GC_NAIVE
orq $(HT_STRING << 8), -8(%rax)
.endif
movq %rax, %r12 # string object base
# read(fd, cell+8, size)

File diff suppressed because it is too large Load diff

View file

@ -632,26 +632,42 @@ The reason we built the GC at all was to let long-running asm HTTP servers not l
Config req/s baseline peak RSS growth KB
RSS KB KB
============================== ========= ========= ========== =============
asm no-GC + ``heap-snapshot`` 484 100 104 **4**
asm GC + ``heap-snapshot`` 462 120 124 **4**
asm no-GC + no snapshot 463 96 45,812 **46,096**
asm GC + no snapshot † — — —
asm no-GC + ``heap-snapshot`` ~300 100 104 **4**
asm GC + ``heap-snapshot`` ~330 120 124 **4**
asm no-GC + no snapshot ~360 96 45,812 **46,096**
asm GC + no snapshot ~410 116 1,088 **972**
============================== ========= ========= ========== =============
† Crashes at first GC — latent conservative-scan bug, see below.
All four cells validate cleanly now:
Three cells validate cleanly:
- **Cells 1 and 2** show that on idiomatic code using ``heap-snapshot``, both binaries hold memory absolutely flat (~4 KB growth over 5,000 requests is normal VM noise). The GC build costs ~5% throughput for a feature the snapshot pattern doesn't need — a meaningful signal that if your server is well-written, GC is optional overhead.
- **Cells 1 and 2** show that on idiomatic code using ``heap-snapshot``, both binaries hold memory absolutely flat (~4 KB growth over 5,000 requests is normal VM noise). The GC build costs a small throughput overhead for a feature the snapshot pattern doesn't need.
- **Cell 3** demonstrates the leak scenario we explicitly designed the GC build to solve. Without ``heap-snapshot``, the no-GC asm server grows **~9 KB per request** — 46 MB over 5,000 requests, heading to OOM on any real workload. This is the bump-only allocator working exactly as documented.
- **Cell 4** is the one that should have been bounded by naive mark-sweep and wasn't. The server crashes at the first GC trigger (~1 MB of allocations into the run), fixed-size workload triggering another variant of the conservative stack scan's type-confusion. The pattern is identical in kind to the 24-byte env/string collision (§6.6.1) we already fixed — probably a 24- or 40-byte response block being walked as something it isn't. The fix requires tightening one more walker; we logged it as a known issue rather than shipping a fix under time pressure.
- **Cell 4** is the use case the GC was built for. With neither ``heap-snapshot`` nor ``heap-restore``, the GC build bounds memory at one chunk (~1 MB) and serves **faster than the leaking no-GC version** because it doesn't pay ``heap_grow`` mmap-every-64-MB costs on repeated allocation. **972 KB of growth** across 5,000 requests is exactly one heap chunk — the collector hit its natural steady state.
**Honest read.** The GC build succeeds at validating the snapshot pattern (cell 2 is the real deployment target for long-running asm servers) but the "use GC instead of snapshots" use case (cell 4) has an outstanding correctness bug. ``heap-snapshot`` + ``heap-restore`` remain the recommended pattern for production asm code; the naive GC serves as a diagnostic backstop and as the control-group baseline for future memory-management work. This is still progress — we now have a concrete failing case to aim the next round of debugging at, rather than a vague worry.
**Precise-type dispatch was the fix.** Cell 4 was crashing at first GC until we replaced the conservative-scan-plus-sentinel-checks walker with a precise one. Every heap block's 8-byte header now carries an explicit type byte at bits 815 (see §6.6.5 below for the redesign), so ``gc_mark_drain``, ``gc_mark_env``, and the arena-escape scan dispatch on the type byte instead of guessing from block size. This eliminated the entire class of "24-byte env vs string" / "40-byte vector vs string" type-confusion bugs we'd been patching one-by-one.
**Honest read.** The GC build now succeeds at the "GC instead of snapshots" use case. ``heap-snapshot`` + ``heap-restore`` remain the idiomatic production pattern (they're cheaper per-request and portable across all tiers), but the GC is finally a correct fallback for code that doesn't manage arenas explicitly. Remaining rough edge: on very heavy sustained allocation workloads (hash-set benchmark at the ~1 MB/iter scale under the GC build) we still see the occasional unbound-variable error that points to a root-scan edge case the precise-type fix didn't completely close. Tracked as a follow-up.
**Reproduce:** ``make bench-gc-http``. Tuning: ``REQUESTS=10000 CONCURRENCY=16 VCAP=524288 bash tests/bench-gc-http.sh``.
6.6.5 Precise Block Typing: Killing a Class of Bugs
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Earlier versions of the meta-GC walkers inferred block type from *size* alone. A 24-byte block could be an env node, a closure, or a 16-character string; a 40-byte block could be a 4-element vector or a 25-character string. The walkers tried to guess and guessed wrong under the conservative stack scan — any stack word whose low 3 bits happened to match ``TAG_SYM`` or ``7`` (vector-family) would be dereferenced, its block's size read from the header, and the walker would interpret subsequent payload bytes as tagged child values. Strings-as-vectors reading 200 bytes past their end was the canonical failure.
We fixed this by adding an explicit type byte to every heap block's header::
# Old: [size:63 | mark:1]
# New: [size:48 | type:8 | flags:8 (mark at bit 0)]
Type constants (``HT_PAIR``, ``HT_CLOSURE``, ``HT_STRING``, ``HT_SYMBOL``, ``HT_VECTOR``, ``HT_HASHTABLE``, ``HT_HASHSET``, ``HT_ENVNODE``, ``HT_CHAINNODE``, ``HT_PADDING``) are set at every ``heap_alloc`` call site in the GC build. Every walker — mark, escape-scan, sweep — now dispatches on the type byte instead of size. A string can never be walked as a vector; an env node can never be confused with a closure.
Cost: one extra ``orq`` at each of ~15 allocation sites (a few nanoseconds per call) and 16 bits of header space per block (negligible given minimum block size is 16 payload bytes + 8 header bytes). Benefit: the entire "conservative scan misidentifies X as Y" class of bugs goes away. Cell 4 of §6.6.4 flipped from "crashes at first GC" to "working correctly" when this landed.
The precise-type change also simplified the walkers: the special-case "is the first word -1 (hash-table) or -2 (hash-set) or a small positive number (vector length)" dispatch in ``gc_mark_drain`` collapsed into a single ``cmp`` on the type byte. ~50 lines of heuristic-guessing code deleted.
7. Portal: Feedback Across Time
----------------------------------------