Key the string tracker on the object, not its address

The defect

track_rstring registers each rb_str_new_static string in the WeakMap under its address:

// Use the string's Id as the key in the `WeakMap`.
let key = str.as_raw();
let _: Value = tracker.funcall("[]=", (key, str))?;

That address is only valid until the next compacting GC. After compaction the string has moved but its entry keeps the old address as key — and when a later string is allocated into the recycled slot, track_rstring inserts it under that same key and evicts the live entry.

The evicted string is still in use by the caller, but update_weak_map no longer iterates it. So the next expand_to_fit — munmap, grow, re-mmap — rewrites every other tracked string's ptr/len and leaves that one pointing into unmapped memory. Reading it then segfaults.

Nothing about this is visible from the value side: the WeakMap entry itself is a perfectly ordinary weak reference, and update_weak_map only ever calls values. It's the key that goes stale.

The fix

Key the map on the string itself. The WeakMap semantics are unchanged — the entry still disappears when the string is collected, and update_weak_map only iterates values — but there is no address to go stale.

One line, plus a comment explaining why the address can't be used.

Verification

Built master and the patched tree in a container, Ruby 4.0.6 aarch64-linux. The reproducer holds 50 slices live in a global (a local would be conservatively pinned and couldn't relocate), compacts between iterations, forces repeated expand_to_fit via upsert_entry, and reads every held string's header and last byte each round:

                                        master        with this patch
GC.verify_compaction_references         3/3 SEGV      3/3 pass
no compaction (control)                 3/3 pass      3/3 pass
plain GC.compact                        1/3 SEGV      3/3 pass

The patched run completes with 8,725 observed relocations of held strings, 100/100 witnesses relocated, and 0 bad reads — so the compaction genuinely happened and the strings genuinely moved; they just stayed tracked.

I also observed the eviction directly, without any crash: holding 200 tracked strings across a compaction plus allocation churn, master drops one of them out of @weak_obj_tracker while all 200 are still live and reachable. The patched build drops none.

Honest note on rate

The 1/3 row matters. Under plain GC.compact this is probabilistic — it needs a recycled slot to collide with a stale key — so I am not claiming a production frequency. It is deterministic under GC.verify_compaction_references, and the mechanism is present whenever the process compacts at all.

Context

Found while auditing the native gems in several Rails applications for dangling VALUEs after GC compaction. This one is a shape I hadn't seen before and worth naming: a VALUE laundered through Ruby as an Integer and used as a lookup key is just as stale after compaction as one cast to void *, and it evades every grep you'd normally use to find these.

The // SAFETY: We're messing with Ruby's internals here, YOLO. block in update_weak_map itself checked out — the RString layout it assumes still matches Ruby 4.0, and I found no problem with the in-place ptr/len/aux rewriting.

Merge request reports

Loading
Loading