Skip to content

v0.21.0

Choose a tag to compare

@sdogruyol sdogruyol released this 26 Aug 07:25
· 254 commits to master since this release

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_object treated 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. @chunks was mutated
    under two locks; unlink_chunk could unmap a still-linked chunk;
    update_heap_bounds_after_unmap overwrote bounds a concurrent map_chunk had
    published; trim_large_cache unmapped while alloc_large issued 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_lock and unmaps after. large_cache_race: 18 of 340
    SIGSEGVs → 0; GCRY_TRIM_UNLOCKED=1 5 of 50 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_block last-chunk cache and start_world index window. The cache
    loaded @last_chunk_idx twice and could index [-1] (libc's malloc header —
    the 0x91 CI crash). The index is read once and verified to contain the
    address. Separately, start_world resumed 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 Thread is now rooted from
    GC.pthread_create until 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_gc now go atomic in GC.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_ANONYMOUS is 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 (mprotect instead
    of munmap) so a SIGSEGV can name the chunk, path, and offset.
  • Thread-death audit. GCRY_THREAD_BLOCK_AUDIT=1 names a dying Thread in
    the collection that frees it; GCRY_DYING_TYPE_ID / make thread-block-audit
    prove the walk; make thread-uaf-sample buys 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.md covers the 33 knobs added since
    v0.20.0; make knob-doc-check fails CI if a GCRY_* has no row.
  • STW stall diagnostics. The suspend wait names the thread it is waiting for
    and asks libc whether that pthread_t still exists (ESRCH vs live).

Changed

  • GCRY_PAGE_DONTNEED=1 is unsound (post-STW MADV_DONTNEED can zero a live
    object; 4 of 28 attempts on make 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_REUSABLE zero-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=1 turns it back on.
  • Dying-type audit skips the old heap on minor collections (was reporting
    every live Thread as dying).
  • CI hang legibility. aarch64 gates are bounded (timeout 300,
    GCRY_STW_WATCHDOG_MS); ec_queue_audit waits give up after 30 s. Crash
    diagnostics ride all three stw_mt_property_test arms, including TLAB and
    Darwin.
  • Pre-commit format checks git ls-files -- '*.cr' instead of walking
    vendored lib/.