Skip to content

freeze does not stop def: a method can be defined on a frozen object, class or module #798

Closed

Description

@tannevaled

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:2755 case bytecode.OpDefineMethod
  • internal/vm/vm.go:2813 case bytecode.OpDefineSMethod
  • internal/vm/singleton.go:32 define_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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions