v0.21.0
Correctness release. Closes the acikturkiye live-string UAF (layout collision
on Hash/union buffers), chunk-list and large-cache races, the Thread birth
use-after-free, and several root-coverage holes (BSS cap, 64-thread stack
bounds, birth/staging overflow).
Fixed
-
Precise layout chosen by a mutator word.
scan_objecttreated a block's
first Int32 as a type id. A raw buffer of union values (Array(JSON::Any), a
Hash entry table) starts with exactly that kind of small integer, so a 64-byte
Hash body was scanned to the wrong map and lost its pointers. Hash-kind bodies
are now word-scanned beside the entry walk; the shape is validated first; a
miss falls back to the conservative scan (demotion included). Acikturkiye
wrk -t4 -c64: 193 of 216 collections missed edges before, 0 of 216
after.bench/log/linux/2026-08-24-acikturkiye-live-string-uaf/FINDINGS.md. -
Chunk list, heap bounds, and large-cache trim races.
@chunkswas mutated
under two locks;unlink_chunkcould unmap a still-linked chunk;
update_heap_bounds_after_unmapoverwrote bounds a concurrentmap_chunkhad
published;trim_large_cacheunmapped whilealloc_largeissued the same
chunk; the lazy sweep did the bounds walk unlocked too. List mutations share
@chunk_list_lock; bounds are read off the chunk index under one lock; trim
detaches under@alloc_lockand unmaps after.large_cache_race: 18 of 340
SIGSEGVs → 0;GCRY_TRIM_UNLOCKED=15 of 5 → 0 serialised.
bench/log/linux/2026-08-25-aarch64-large-cache-locked-arm/FINDINGS.md,
bench/log/linux/2026-08-23-acik-crash/FINDINGS.md. -
find_blocklast-chunk cache andstart_worldindex window. The cache
loaded@last_chunk_idxtwice and could index[-1](libc's malloc header —
the0x91CI crash). The index is read once and verified to contain the
address. Separately,start_worldresumed threads before clearing
@world_stopped, so mutators took the unlocked index path; the flag is
cleared first.make find-block-race,make stw-index-race. -
Thread birth use-after-free. A
Threadis now rooted from
GC.pthread_createuntil it publishes on Crystal's list. Overflow of the
64-slot table used to drop the root (the UAF reopened past the 64th birth
since the last collection); it now roots anyway and leaks, which is the
deliberate trade. A full staging table drained nothing and dropped the
newest birth; it now drains published entries and evicts the oldest.
make thread-birth-root,make thread-staging. -
Global roots and stack coverage. BSS larger than 1 MiB was refused as a
root range, so every class var and constant slot was dropped; the cap is gone
and oversized ranges are scanned in chunks. The pthread stack-bounds snapshot
stopped at 64 threads and reported full coverage; the table grows, and the
visit is counted before the capacity check.make static-bss-roots. -
Heap counters lost updates.
live_objects/total_bytes/
bytes_since_gcnow go atomic inGC.pthread_create, before the call.
GCRY_HEAP_COUNTERS_ATOMIC=0/1.
bench/log/linux/2026-08-20-heap-counter-cost/FINDINGS.md. -
Darwin compile.
LibC::MAP_ANONYMOUSis no longer redefined when Crystal
already has it (x86_64 macOS).bss_size_cap=lives on both platforms.
make darwin-typecheck. -
Crash reporter and dying-audit false readings. Out-of-span faults no
longer exclude a swept object; reissued-block flags are not a free-path
verdict; the address-space audit no longer reports its own frames as a scan
hole; the dying audit records watched types below the 384-byte band.
Added
GCRY_UNMAP_GUARD=1— released chunks stay identified (mprotectinstead
ofmunmap) so a SIGSEGV can name the chunk, path, and offset.- Thread-death audit.
GCRY_THREAD_BLOCK_AUDIT=1names a dyingThreadin
the collection that frees it;GCRY_DYING_TYPE_ID/make thread-block-audit
prove the walk;make thread-uaf-samplebuys CI samples. The report says
whether the object is still on Crystal's list. - Race gates.
make large-cache-race,make find-block-race. - Knob reference.
docs/HARDENING.mdcovers the 33 knobs added since
v0.20.0;make knob-doc-checkfails CI if aGCRY_*has no row. - STW stall diagnostics. The suspend wait names the thread it is waiting for
and asks libc whether thatpthread_tstill exists (ESRCHvs live).
Changed
GCRY_PAGE_DONTNEED=1is unsound (post-STWMADV_DONTNEEDcan zero a live
object; 4 of 28 attempts onmake page-release-corruption). Documented as
such, warns at boot;GCRY_DISABLE_PAGE_RELEASE=1/GCRY_DISABLE_MADVISE=1
now actually skip Darwin's walk. Defect still open.- Free-page release is opt-in on macOS too (was the one platform where it
shipped on).MADV_FREE_REUSABLEzero-fills a reclaimed page, so the same
window is reachable there — read from the code, since the gate has no Darwin
runner, which is why the default was the wrong place to leave it. Costs macOS
RSS;GCRY_PAGE_DONTNEED=1turns it back on. - Dying-type audit skips the old heap on minor collections (was reporting
every liveThreadas dying). - CI hang legibility. aarch64 gates are bounded (
timeout 300,
GCRY_STW_WATCHDOG_MS);ec_queue_auditwaits give up after 30 s. Crash
diagnostics ride all threestw_mt_property_testarms, including TLAB and
Darwin. - Pre-commit format checks
git ls-files -- '*.cr'instead of walking
vendoredlib/.