[ruby-core:126434] [Ruby Bug#22251] ObjectSpace::WeakMap#size can return a stale value
Issue #22251 has been reported by Eregon (Benoit Daloze). ---------------------------------------- Bug #22251: ObjectSpace::WeakMap#size can return a stale value https://bugs.ruby-lang.org/issues/22251 * Author: Eregon (Benoit Daloze) * Status: Open * ruby -v: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin25] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- ``` $ ruby -e '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 '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. -- https://bugs.ruby-lang.org/
Issue #22251 has been updated by headius (Charles Nutter). 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) 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. ---------------------------------------- Bug #22251: ObjectSpace::WeakMap#size can return a stale value https://bugs.ruby-lang.org/issues/22251#change-118584 * Author: Eregon (Benoit Daloze) * Status: Open * ruby -v: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin25] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- ``` $ ruby -e '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 '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. -- https://bugs.ruby-lang.org/
Issue #22251 has been updated by Eregon (Benoit Daloze). 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... https://github.com/jruby/jruby/blob/4d041a906d427b7fee577b9a2938e2fcda6c5522... 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. ---------------------------------------- Bug #22251: ObjectSpace::WeakMap#size can return a stale value https://bugs.ruby-lang.org/issues/22251#change-118599 * Author: Eregon (Benoit Daloze) * Status: Open * ruby -v: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin25] * Backport: 3.3: UNKNOWN, 3.4: UNKNOWN, 4.0: UNKNOWN ---------------------------------------- ``` $ ruby -e '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 '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. -- https://bugs.ruby-lang.org/
Issue #22251 has been updated by Eregon (Benoit Daloze). 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:
`master` b52af71445 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 ``` ---------------------------------------- Bug #22251: ObjectSpace::WeakMap#size can return a stale value https://bugs.ruby-lang.org/issues/22251#change-118600 * Author: Eregon (Benoit Daloze) * Status: Closed * ruby -v: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin25] * Backport: 3.3: WONTFIX, 3.4: REQUIRED, 4.0: REQUIRED ---------------------------------------- ``` $ ruby -e '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 '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. -- https://bugs.ruby-lang.org/
Issue #22251 has been updated by Eregon (Benoit Daloze). 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. ---------------------------------------- Bug #22251: ObjectSpace::WeakMap#size can return a stale value https://bugs.ruby-lang.org/issues/22251#change-118602 * Author: Eregon (Benoit Daloze) * Status: Closed * ruby -v: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin25] * Backport: 3.3: WONTFIX, 3.4: WONTFIX, 4.0: WONTFIX ---------------------------------------- ``` $ 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. -- https://bugs.ruby-lang.org/
Issue #22251 has been updated by headius (Charles Nutter).
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. ---------------------------------------- Bug #22251: ObjectSpace::WeakMap#size can return a stale value https://bugs.ruby-lang.org/issues/22251#change-118605 * Author: Eregon (Benoit Daloze) * Status: Closed * ruby -v: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin25] * Backport: 3.3: WONTFIX, 3.4: WONTFIX, 4.0: WONTFIX ---------------------------------------- ``` $ 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. -- https://bugs.ruby-lang.org/
Issue #22251 has been updated by Eregon (Benoit Daloze). 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) ---------------------------------------- Bug #22251: ObjectSpace::WeakMap#size can return a stale value https://bugs.ruby-lang.org/issues/22251#change-118606 * Author: Eregon (Benoit Daloze) * Status: Closed * ruby -v: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [arm64-darwin25] * Backport: 3.3: WONTFIX, 3.4: WONTFIX, 4.0: WONTFIX ---------------------------------------- ``` $ 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. -- https://bugs.ruby-lang.org/
participants (2)
-
Eregon (Benoit Daloze) -
headius (Charles Nutter)