Issue #22200 has been updated by ivoanjo (Ivo Anjo). To be clear, on the dd-trace-rb / `datadog` gem side we plan to move off of `_id2ref`. I guess @navidemad's point is more to report that `_id2ref` is already broken on 4.0, whereas before we were merely considering it was still working, but marked for deprecation ;) ---------------------------------------- Bug #22200: ObjectSpace._id2ref can return a different object than the id's owner on Ruby 4.0 (stale id2ref_tbl entry for objects with generic fields) https://bugs.ruby-lang.org/issues/22200#change-118118 * Author: navidemad (Navid Emad) * Status: Feedback * ruby -v: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin23] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- ## Summary On Ruby 4.0.x, `ObjectSpace._id2ref(id)` can return a **different, unrelated live object** instead of raising `RangeError`, after the object that owned the id has been garbage collected and its heap slot reused. Observed returns in the reproduction below include recycled strings, `Thread::Backtrace` instances and previously-raised `RangeError` exception objects. Ruby ≤ 3.4 is not affected. Ruby master is not affected because `_id2ref` was removed ([Feature #22135], commit df062232), so this is a 4.0-branch-only bug — but 4.0 is the current stable release and `_id2ref` is still callable there (with a deprecation warning). Tested and reproduced on 4.0.2, 4.0.5 and 4.0.6 (arm64-darwin23); production crashes caused by the same mechanism were observed on 4.0.5 x86_64-linux (see "Real-world impact"). ## Root cause Since the object_id redesign ([Feature #15408]), the id lives in the object itself and `id2ref_tbl` (lazily built on first `_id2ref` call) is the only id → object mapping. Entry deletion happens in `obj_free_object_id`, called from sweep via `rb_gc_obj_free_vm_weak_references`, and depends on where the id is stored (gc.c, v4.0.5, `obj_free_object_id`): * `T_OBJECT` (id in its own fields) and `T_CLASS`/`T_MODULE` (id in `RCLASS->object_id`): the entry is deleted when the owner itself is swept — same as ≤ 3.4. * **Every other type** (String, Array, Hash, Data, ... — id stored in the companion `T_IMEMO/fields`): the owner's sweep hits `default: return;` ("For generic_fields, the T_IMEMO/fields is responsible for freeing the id") and only detaches the imemo via `rb_free_generic_ivar`, which does not touch `id2ref_tbl`. The entry is deleted only when **the imemo is swept**. The owner and its imemo generally live in different size pools, so under lazy sweeping their sweeps are separated by arbitrary amounts of Ruby execution. In that window `id2ref_tbl` still maps a valid id to a freed slot. The only gate on lookup is `rb_gc_impl_garbage_object_p`, which catches `T_NONE`/`T_MOVED`/`T_ZOMBIE` and unswept garbage during lazy sweeping — it cannot catch a *recycled* slot, whose new occupant is a perfectly live object. `object_id_to_ref` then returns that occupant instead of raising `RangeError`. (Note the weak-table pass over `id2ref_tbl` via `rb_gc_vm_weak_table_foreach` only runs during compaction reference updating, so ordinary GC cycles never purge these dangling entries.) On ≤ 3.4 this window cannot exist: `obj_free_object_id` deleted both `obj_to_id_tbl`/`id_to_obj_tbl` entries for every freed object in the same sweep step that frees the slot. ## Reproduction ```ruby # MODE=string -> owners are Strings (generic fields, deferred deletion) => bug on 4.0 # MODE=object -> owners are T_OBJECTs (id in own fields, eager deletion) => control Warning[:deprecated] = false OWNERS = Integer(ENV["OWNERS"] || 50_000) MODE = ENV["MODE"] || "string" BATCHES = Integer(ENV["BATCHES"] || 60) PROBES_PER_BATCH = Integer(ENV["PROBES_PER_BATCH"] || 300) RECYCLERS_PER_BATCH = Integer(ENV["RECYCLERS_PER_BATCH"] || 1_000) srand(42) def probe(id) ObjectSpace._id2ref(id) end # Make id2ref_tbl exist before owners get their ids, so each object_id call # inserts an entry (gc.c object_id0). anchor = Object.new probe(anchor.object_id) # Allocate all owners first (their pages come earlier in sweep order), then # take object_ids afterwards (imemos land on later pages / another pool). owners = if MODE == "string" Array.new(OWNERS) { |i| +"owner-#{i}" } else Array.new(OWNERS) { |i| o = Object.new; o.instance_variable_set(:@owner, i); o } end ids = owners.map(&:object_id) owners = nil GC.start(full_mark: true, immediate_sweep: false) dead = 0 alive_original = 0 wrong = Hash.new(0) wrong_samples = [] recyclers = [] BATCHES.times do |batch| recyclers.concat(Array.new(RECYCLERS_PER_BATCH) { +"recycler-#{batch}" }) PROBES_PER_BATCH.times do id = ids.sample begin obj = probe(id) original = if MODE == "string" obj.instance_of?(String) && obj.start_with?("owner-") else obj.instance_of?(Object) && obj.instance_variable_defined?(:@owner) end if original alive_original += 1 # kept alive by conservative stack refs; not a bug else wrong[obj.class] += 1 wrong_samples << obj.inspect[0, 60] if wrong_samples.size < 10 end rescue RangeError dead += 1 end end end puts RUBY_DESCRIPTION puts "mode=#{MODE} owners=#{OWNERS}" puts " RangeError (correct): #{dead}" puts " returned original (alive): #{alive_original}" puts " returned WRONG object (BUG): #{wrong.values.sum} #{wrong.sort_by { |_, v| -v }.to_h}" wrong_samples.each { |s| puts " e.g. #{s}" } exit(wrong.values.sum > 0 ? 1 : 0) ``` Results (18,000 probes per run): | Ruby | owners | wrong-object returns | |---|---|---| | 4.0.2 | String (generic fields) | 4 | | 4.0.5 | String (generic fields) | 12 | | 4.0.6 | String (generic fields) | 12 | | 4.0.5 | `T_OBJECT` (control) | 0 | | 3.4.10 | String (control) | 0 | Sample of what `_id2ref(dead_string_id)` returned instead of raising, on 4.0.6: ``` "recycler-0" # the object recycled into the slot #<Thread::Backtrace:0x0000000122507710> # a VM-internal object #<RangeError: "67488" is a recycled object> # an exception raised by an earlier probe ``` Two notes for reproducing: * `GC.disable` must not be used: under `dont_gc`, allocation never drives lazy sweeping (`heap_prepare` grows the heap instead), so slots are never recycled and every probe is correctly caught by the unswept-garbage branch of `rb_gc_impl_garbage_object_p`. * The dangling-entry zone moves with the sweep cursors (`gc_sweep_continue` advances one page in every heap per step), hence the randomly-sampled probes. ## Real-world impact This is the root cause of https://github.com/DataDog/dd-trace-rb/issues/5936: the datadog gem's heap profiler tracks sampled objects by object_id and resurrects them with `_id2ref` at serialization time, relying on the ≤ 3.4 contract "the exact object or `RangeError`". On 4.0 it receives arbitrary slot occupants and calls `rb_obj_memsize_of()` on them; production crash dumps show SIGSEGVs in three distinct type branches of `rb_obj_memsize_of` (`T_CLASS` classext walk, `T_REGEXP` `onig_memsize`, `T_DATA` `rb_objspace_data_type_memsize`) — full dumps in https://gist.github.com/navidemad/e6ed6e38fad5b3ebe9e89f553f7562ba. Datadog has since disabled the feature on 4.0 (https://github.com/DataDog/dd-trace-rb/pull/6022). Beyond that particular consumer, `_id2ref` handing out arbitrary live objects (including VM-internal ones like `Thread::Backtrace`) to any caller holding a stale integer id seems undesirable for remaining `_id2ref` users (e.g. DRb) even during the deprecation period. ## Possible fix direction At owner sweep time the id is no longer reachable from the owner (its shape may already have been reset by the generic-fields weak-table pass), so the natural place to delete the `id2ref_tbl` entry for a dead generic-fields owner appears to be the GC's weak-table processing of `generic_fields_tbl` (`vm_weak_table_gen_fields_foreach`, `ST_DELETE` case): at that point the imemo is still intact, so its id can be read and the `id2ref_tbl` entry removed there, restoring the ≤ 3.4 guarantee that an entry never outlives its owner. [Feature #15408]: https://bugs.ruby-lang.org/issues/15408 [Feature #22135]: https://bugs.ruby-lang.org/issues/22135 -- https://bugs.ruby-lang.org/