Bug #22251
closedObjectSpace::WeakMap#size can return a stale value
Description
$ ruby -e 'GC.disable; m = ObjectSpace::WeakMap.new; Thread.new { m[Object.new] = Object.new }.join; GC.start; p m.size; p m.keys; p m.size'
1
[]
0
The first line is 1 but I would expect 0.
Interestingly calling #keys seems to force an update.
If I GC twice then it seems correct:
$ ruby -e 'GC.disable; m = ObjectSpace::WeakMap.new; Thread.new { m[Object.new] = Object.new }.join; GC.start; GC.start; p m.size; p m.keys; p m.size'
0
[]
0
Could we decrease size whenever we "remove" an entry?
Apologies if that's already known, I searched for an existing issue for this but didn't find anything.
Updated by nobu (Nobuyoshi Nakada) 23 days ago
master b52af71445 works as expected.
Updated by headius (Charles Nutter) 23 days ago
The implementation of WeakMap on JRuby probably behaves the same, because it uses weak references that are only cleaned along certain code paths.
As you (@Eregon (Benoit Daloze)) know, the JVM GC will vacate weak references, but handling the now-dead reference object is up to user code.
That may be done lazily, via "cleaner" code in other map operations. In this example, the keys method may be triggering such a cleanup, and doing the same in size would fix your issue at the cost of always polling for dead slots.
It can also be done actively, via a separate thread that waits for references to be vacated. In that case, you're waiting for the GC, thread scheduler, and various memory caches to finish cleanup and propagate the results.
In both strategies, there's few guarantees that the remaining references within the map are actually occupied.
In your example, having size run clean-up logic might fix the issue, but I would strongly recommend not requiring that such methods deterministically reflect the actual occupation of the map. Better to explicitly say that the size only reflects recent occupation of the map, and may be inaccurate until all GC-related cleanup has happened.
I'd also point out that on other runtimes like the JVM, forcing a single GC may not fully vacate a weak reference, since there may be complicated chains of references to resolve or finalization/cleaner logic yet to run. The JVM spec makes no guarantees about when objects are collected, which also means there's no guarantees about when weak references are cleared. Specifying behavior like you want across all Ruby implementations would be very problematic.
Updated by Eregon (Benoit Daloze) 21 days ago
Both Java's WeakHashMap and JRuby's variant clean references in size(), so there is no issue there:
https://github.com/openjdk/jdk/blob/05eaf783b270ec5cd901704c5e16b6201de7a9e4/src/java.base/share/classes/java/util/WeakHashMap.java#L391
https://github.com/jruby/jruby/blob/4d041a906d427b7fee577b9a2938e2fcda6c5522/core/src/main/java/org/jruby/util/collections/WeakValuedMap.java#L74
I'm well aware it's tricky to GC an object reliably, but that's not the point of this issue, it's about consistency in what gets reported.
Updated by Eregon (Benoit Daloze) 21 days ago
- Status changed from Open to Closed
- Backport changed from 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN to 3.3: WONTFIX, 3.4: REQUIRED, 4.0: REQUIRED
My reproducer isn't prefect because the 1; []; 0 output would be correct if a GC happens at the start of m.keys, but we can disable the GC to resolve that concern and make it fully reliable.
nobu (Nobuyoshi Nakada) wrote in #note-1:
masterb52af71445 works as expected.
Indeed, I should have checked, I'll close this and mark for backport then, both 3.4 and 4.0 seem affected, in fact since ruby-3.3.0-preview2:
$ ruby -ve 'GC.disable; m = ObjectSpace::WeakMap.new; Thread.new { m[Object.new] = Object.new }.join; GC.start; p m.size; p m.keys; p m.size'
ruby 4.1.0dev (2026-08-08T08:04:35Z master e56f452e46) +PRISM [x86_64-linux]
0
[]
0
$ docker run -it -e ALL_RUBY_SINCE=3.0.0 ghcr.io/ruby/all-ruby /all-ruby/all-ruby -e 'GC.disable; m = ObjectSpace::WeakMap.new; Thread.new { m[Object.new] = Object.new }.join; GC.start; p m.size; p m.keys; p m.size'
ruby-3.0.0 0
[]
0
...
ruby-3.3.0-preview1 0
[]
0
ruby-3.3.0-preview2 1
[]
0
...
ruby-4.0.6 1
[]
0
Updated by Eregon (Benoit Daloze) 21 days ago
- Description updated (diff)
Updated by Eregon (Benoit Daloze) 21 days ago
ยท Edited
- Backport changed from 3.3: WONTFIX, 3.4: REQUIRED, 4.0: REQUIRED to 3.3: WONTFIX, 3.4: WONTFIX, 4.0: WONTFIX
I bisected it to https://github.com/ruby/ruby/commit/b2feb09efe.
This seems big to backport, and I agree it's not so important, so marking all versions as WONTFIX.
#size is just st_table_size(w->table) so what changed is that purging entries was lazy before that commit and eager after (rb_wmap_handle_weak_references).
The behavior on master is better so I'll make a PR to add a CRuby test for it so we don't regress this unknowingly: https://github.com/ruby/ruby/pull/18407
Updated by headius (Charles Nutter) 21 days ago
Both Java's WeakHashMap and JRuby's variant clean references in size(), so there is no issue there:
But your "failing" example only runs a single GC, which may not always be sufficient to clear the reference, in which case the size would not decrease. Ergo it's not deterministic and shouldn't be relied upon.
Updated by Eregon (Benoit Daloze) 21 days ago
headius (Charles Nutter) wrote in #note-7:
But your "failing" example only runs a single GC, which may not always be sufficient to clear the reference, in which case the size would not decrease. Ergo it's not deterministic and shouldn't be relied upon.
It is deterministic on CRuby (m.keys is always empty after a single GC), and this issue is about CRuby's behavior.
(yes this pattern doesn't reliably GC the object on JRuby & TruffleRuby)