Repository navigation
Description
Summary
freeze is a hardening boundary — libraries freeze a config object or a module
precisely to stop runtime modification. In rbgo the most natural way to modify
a class, the def keyword, goes straight through it, while every reflective
equivalent is correctly refused.
Measured
Same program, both engines, today (rbgo at fix/rbgo-version-flag, MRI
ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin25]):
| operation | MRI 4.0.7 | rbgo |
|---|---|---|
"x".freeze << "y" |
FrozenError | FrozenError |
[].freeze << 1 |
FrozenError | FrozenError |
{}.freeze[:a] = 1 |
FrozenError | FrozenError |
o.freeze.instance_variable_set(:@a, 1) |
FrozenError | FrozenError |
c = Class.new.freeze; c.send(:define_method, :z) {} |
FrozenError | FrozenError |
c.send(:attr_accessor, :z) |
FrozenError | FrozenError |
c.send(:remove_method, :z) |
FrozenError | FrozenError |
Module.new.freeze.const_set(:Z, 1) |
FrozenError | FrozenError |
o = Object.new.freeze; o.define_singleton_method(:foo) {} |
FrozenError | no raise |
o = Object.new.freeze; o.instance_eval("def foo; end") |
FrozenError | no raise |
c = Class.new.freeze; c.class_eval { def z; end } |
FrozenError | no raise |
The gap is bounded and specific: the frozen check is implemented, and
vm.raiseFrozen / vm.checkFrozen are called from the reflective API
throughout. What is missing is the check on the method-definition path:
internal/vm/vm.go:2755case bytecode.OpDefineMethodinternal/vm/vm.go:2813case bytecode.OpDefineSMethodinternal/vm/singleton.go:32define_singleton_method
In MRI every one of these converges on rb_class_modify_check(klass)
(eval.c), which raises before the method entry is made — which is why MRI's
reflective and def paths cannot disagree, and rbgo's can.
Why this is more than a conformance nit
freeze on a module or class is used as a defence: it is how a library says
"do not monkey-patch me after boot". Code that relies on it — Config.freeze,
a frozen plugin registry, a frozen DSL namespace — is not protected under
rbgo: the attacker-supplied or plugin-supplied def simply works, and nothing
is raised to notice it. Everything the defender would reach for to test the
protection (define_method, const_set) behaves correctly, so a test of the
hardening passes while the hardening is absent.
Why the conformance sweep does not catch it
language/def_spec.rb covers exactly these three cases at lines 100, 110 and
288. It sits in BASELINE at OK language/def_spec.rb 61, and the ratchet is
shrink-only: a file that never passed those examples stays at its number. They
are real failures, counted as such, with nothing pushing on them.
Those three examples use the inline form
msg_class = ruby_version_is("4.0") ? "Module" : "module", so they do run —
but see #799 on CUR_VERSION, because they currently assert the pre-4.0
wording.
Expected
All eleven rows raise FrozenError, with MRI 4.0's capitalised descriptor
(can't modify frozen Class: ..., Module, Object — 4.0 capitalised what
3.x spelled in lower case; both the 4.0.7 binary and the spec's own
ruby_version_is("4.0") ternary agree on that).
Reproducer: the matrix above, as one script, is in the PR that will fix this.
🤖 Generated with Claude Code
Activity