From 624a10d5744d106913b665c88a9d6ae11716ee6d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 2 Nov 2026 11:28:54 +0100 Subject: [PATCH 1/2] vm: GC.config(hash) answers the full config from Ruby 4.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `core/gc/config_spec.rb` at 4.0.9: `pass=7 fail=1` → **`pass=8 fail=0`**. ``` expected {} to == {implementation: "rbgo"} ``` What the Hash form RETURNS changed, and ruby/spec asserts it **both ways**, which is how a reader can tell a version swap from a defect: ```ruby ruby_version_is ""..."4.0" do it "returns the same as GC.config but without the :implementation key" ruby_version_is "4.0" do it "returns the same as GC.config, including the :implementation key" ``` (core/gc/config_spec.rb:43 and :50). So from 4.0 the setter answers with the full configuration — the same thing the no-argument form returns, `GC.config({}).should == previous`. For rbgo that is the single read-only `:implementation` key, which is why the pre-4.0 answer was an empty Hash. ## The unit test moved with the version, not because it was wrong `gc_test.go` pinned the 3.4 contract: ```go {`p GC.config({})`, "{}\n"}, {`p (GC.config(foo: "bar") == {})`, "true\n"}, ``` Both rows now assert the 4.0 answer, with the two guards quoted above beside them — a reader who finds this diff later needs to know the expectation changed because the target version did, not because the old one was a mistake. That distinction is the whole reason `BASELINE` is not being regenerated for this branch (#799). Refs #799 Co-authored-by: Claude Opus 5 --- internal/vm/gc.go | 27 ++++++++++++++++++++------- internal/vm/gc_test.go | 19 +++++++++++++++---- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/internal/vm/gc.go b/internal/vm/gc.go index 61a58445..259ebb67 100644 --- a/internal/vm/gc.go +++ b/internal/vm/gc.go @@ -259,10 +259,21 @@ func (vm *VM) registerGC() { // tunables. rbgo runs on Go's GC and exposes no tunable settings, so the // configuration is just the read-only :implementation name: with no argument // (or nil) it returns {implementation: "rbgo"}; with a Hash it applies the - // (empty) set of writable settings — ignoring unknown keys — and returns the - // settings without the global :implementation key, but raises ArgumentError if - // asked to write the read-only :implementation. Any other argument type raises - // ArgumentError, matching MRI. + // (empty) set of writable settings — ignoring unknown keys — and raises + // ArgumentError if asked to write the read-only :implementation. Any other + // argument type raises ArgumentError, matching MRI. + // + // ⛔ What the Hash form RETURNS changed in Ruby 4.0, and ruby/spec asserts it + // both ways: + // + // ruby_version_is ""..."4.0" "returns the same as GC.config but WITHOUT the :implementation key" + // ruby_version_is "4.0" "returns the same as GC.config, INCLUDING the :implementation key" + // (core/gc/config_spec.rb:43 and :50) + // + // So from 4.0 the setter answers with the full configuration, exactly what + // the no-argument form returns — `GC.config({}).should == previous`. Here + // that is the one global key, which is why the pre-4.0 answer was an empty + // Hash and the 4.0 answer is `{implementation: "rbgo"}`. def("config", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { fullConfig := func() *object.Hash { h := object.NewHash() @@ -282,9 +293,11 @@ func (vm *VM) registerGC() { if _, ok := a.Get(object.NewString("implementation")); ok { return raise("ArgumentError", `Attempting to set read-only key "Implementation"`) } - // No writable settings exist on Go's GC; unknown keys are ignored. The - // return value mirrors the settings without the global :implementation key. - return object.NewHash() + // No writable settings exist on Go's GC; unknown keys are ignored. From + // 4.0 the return value is the FULL configuration — the same thing the + // no-argument form answers — rather than the settings with the global + // key stripped out. + return fullConfig() default: return raise("ArgumentError", "expected keyword arguments, got %s", vm.classOf(args[0]).name) } diff --git a/internal/vm/gc_test.go b/internal/vm/gc_test.go index 40fe9cfc..d1ec93ec 100644 --- a/internal/vm/gc_test.go +++ b/internal/vm/gc_test.go @@ -88,12 +88,23 @@ func TestGCModule(t *testing.T) { {`p GC::OPTS.is_a?(Array)`, "true\n"}, // config: no-arg and nil return {implementation: "rbgo"}; an options Hash - // returns the (empty) writable settings and ignores unknown keys; writing the - // read-only :implementation (Symbol or String) raises; a non-hash raises. + // ignores unknown keys; writing the read-only :implementation (Symbol or + // String) raises; a non-hash raises. + // + // ⛔ What the Hash form RETURNS changed in Ruby 4.0, and the two rows below + // moved with the version rather than because rbgo was wrong — ruby/spec + // asserts it both ways, which is how a reader can tell the two apart: + // + // ruby_version_is ""..."4.0" "returns the same as GC.config but WITHOUT the :implementation key" + // ruby_version_is "4.0" "returns the same as GC.config, INCLUDING the :implementation key" + // (core/gc/config_spec.rb:43 and :50) + // + // So from 4.0 the setter answers with the full configuration — here the one + // global key — where before it answered an empty Hash. {`c = GC.config; p [c.is_a?(Hash), c[:implementation]]`, `[true, "rbgo"]` + "\n"}, {`p GC.config(nil) == GC.config`, "true\n"}, - {`p GC.config({})`, "{}\n"}, - {`p (GC.config(foo: "bar") == {})`, "true\n"}, + {`p GC.config({})`, `{implementation: "rbgo"}` + "\n"}, + {`p (GC.config(foo: "bar") == GC.config)`, "true\n"}, {`begin; GC.config(implementation: "x"); rescue => e; p [e.class, e.message]; end`, `[ArgumentError, "Attempting to set read-only key \"Implementation\""]` + "\n"}, {`begin; GC.config("implementation" => "x"); rescue => e; p e.class; end`, "ArgumentError\n"}, {`begin; GC.config([]); rescue => e; p e.class; end`, "ArgumentError\n"}, From 0c27b3f56f0b46bc8ad643118d73217ced6b8478 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 5 Nov 2026 13:17:57 +0100 Subject: [PATCH 2/2] Pin the identity of the Hash GC.config now hands back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parent commit is cherry-picked unchanged from move-to-4.0.9 (3705961f, 2026-11-02), a branch that was pushed and never proposed; see #799. I verified its claim against MRI 4.0.7 rather than taking it, with a probe that compares SHAPE only — :implementation's value is necessarily per-engine ("rbgo" here), so comparing the value would diverge for a reason that is not the behaviour at issue. Ten rows, AGREE: config has :implementation true config({}) == config true config({}) has :implementation true config(unknown) == config true config(unknown) is a Hash Hash an unknown key is not kept false config is not frozen false GC.config.equal?(GC.config) false config(nil) no raise config(1) ArgumentError ## What the added rows are for Answering the FULL config where the answer used to be {} makes the identity of that Hash matter, and nothing pinned it. If the method handed back the VM's own Hash, a caller mutating the result would be editing the garbage collector's configuration — harmless while the only key is read-only, and a defect the first time a writable one is added. It does not: each call builds a fresh, unfrozen copy, and an unknown key is ignored rather than stored. Four rows, each measured equal on 4.0.7. core/gc/config_spec.rb: pass=7 fail=1 -> pass=8 fail=0 error=0. Refs #799 Co-Authored-By: Claude Opus 5 --- internal/vm/gc_test.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/vm/gc_test.go b/internal/vm/gc_test.go index d1ec93ec..5b61e83f 100644 --- a/internal/vm/gc_test.go +++ b/internal/vm/gc_test.go @@ -105,6 +105,14 @@ func TestGCModule(t *testing.T) { {`p GC.config(nil) == GC.config`, "true\n"}, {`p GC.config({})`, `{implementation: "rbgo"}` + "\n"}, {`p (GC.config(foo: "bar") == GC.config)`, "true\n"}, + // ⛔ Returning the FULL config where the answer used to be {} makes the + // identity of that Hash matter: a caller who mutates it must not reach the + // VM's own configuration. Each call answers a fresh, unfrozen copy, and an + // unknown key is ignored rather than stored. Measured equal on MRI 4.0.7. + {`p GC.config.equal?(GC.config)`, "false\n"}, + {`p GC.config.frozen?`, "false\n"}, + {`h = GC.config; h[:implementation] = "zzz"; p GC.config[:implementation]`, `"rbgo"` + "\n"}, + {`GC.config(foo: "bar"); p GC.config.key?(:foo)`, "false\n"}, {`begin; GC.config(implementation: "x"); rescue => e; p [e.class, e.message]; end`, `[ArgumentError, "Attempting to set read-only key \"Implementation\""]` + "\n"}, {`begin; GC.config("implementation" => "x"); rescue => e; p e.class; end`, "ArgumentError\n"}, {`begin; GC.config([]); rescue => e; p e.class; end`, "ArgumentError\n"},