Bug #22200
closedObjectSpace._id2ref can return a different object than the id's owner on Ruby 4.0 (stale id2ref_tbl entry for objects with generic fields)
Description
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) andT_CLASS/T_MODULE(id inRCLASS->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 hitsdefault: return;("For generic_fields, the T_IMEMO/fields is responsible for freeing the id") and only detaches the imemo viarb_free_generic_ivar, which does not touchid2ref_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¶
# 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.disablemust not be used: underdont_gc, allocation never drives lazy sweeping (heap_preparegrows the heap instead), so slots are never recycled and every probe is correctly caught by the unswept-garbage branch ofrb_gc_impl_garbage_object_p.- The dangling-entry zone moves with the sweep cursors (
gc_sweep_continueadvances 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.
Updated by nobu (Nobuyoshi Nakada) about 18 hours ago
- Status changed from Open to Feedback
ObjectSpace._id2ref does not guarantee the reference to the object, so it can happen of course.
And it has been deprecated for years, and is removed in 4.1.
You should use WeakRef or WeakMap instead.
Updated by ivoanjo (Ivo Anjo) about 18 hours ago
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 ;)