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..5b61e83f 100644 --- a/internal/vm/gc_test.go +++ b/internal/vm/gc_test.go @@ -88,12 +88,31 @@ 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"}, + // ⛔ 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"},