Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
[![Docs](https://img.shields.io/badge/docs-mkdocs--material-9B1C2E)](https://go-embedded-ruby.github.io/docs/)
[![License](https://img.shields.io/badge/license-BSD--3--Clause-blue)](LICENSE)
[![Go](https://img.shields.io/badge/go-1.27.1%2B-00ADD8)](https://go.dev/dl/)
[![Release](https://img.shields.io/badge/release-v0.1.0-blue)](https://github.com/go-embedded-ruby/ruby/releases/tag/v0.1.0)
[![ruby/spec](https://img.shields.io/badge/ruby%2Fspec-23%2C483%20examples%20passing-1a7f37)](#runtime-conformance--rubyspec)
[![Release](https://img.shields.io/github/v/release/go-embedded-ruby/ruby?label=release&color=blue)](https://github.com/go-embedded-ruby/ruby/releases/latest)
[![ruby/spec](https://img.shields.io/badge/ruby%2Fspec-23%2C512%20examples%20passing-1a7f37)](#runtime-conformance--rubyspec)

**A Ruby interpreter written in pure Go, with cgo disabled** — so you can embed it
in a Go program with `import "github.com/go-embedded-ruby/ruby"`, or ship it as a
single static binary that cross-compiles wherever Go does, with no C toolchain and
no libruby.

**How complete is it?** It runs **23,483** of ruby/spec's `language/` + `core/`
**How complete is it?** It runs **23,512** of ruby/spec's `language/` + `core/`
examples ([what that counts](#runtime-conformance--rubyspec)) — a large and growing
subset of the language, and **not** a drop-in replacement for CRuby. Read
[What does not work yet](#what-does-not-work-yet) before you depend on it; the
Expand Down Expand Up @@ -982,7 +982,7 @@ specification of the language — through `rbgo` under a minimal MSpec-compatibl
shim, and gates CI on a **per-file** baseline
([`scripts/conformance/rubyspec/`](scripts/conformance/rubyspec/), in
`BASELINE`). No file may pass fewer examples than its record or stop loading, so
measured language conformance moves in one direction; the total, **23,483**, is
measured language conformance moves in one direction; the total, **23,512**, is
a derived summary rather than the thing gated. Run it with
`scripts/conformance/rubyspec/run.sh`, and see *Runtime conformance* under
*Status* for the full breakdown.
Expand Down
87 changes: 87 additions & 0 deletions internal/vm/builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,14 @@ func (vm *VM) bootstrap() {
}
case *RClass:
o.frozen = true
case *Regexp:
// isFrozen has read Regexp.frozen all along and freeze never wrote it,
// so `Regexp.new("a").freeze.frozen?` answered false where MRI says
// true. A literal /a/ is born frozen, which is how this stayed
// invisible: the common case was already right. Found by comparing
// this switch against isFrozen's TERM BY TERM -- Regexp was the only
// type in one and not the other, which a spot check would have missed.
o.frozen = true
default:
// Bound/UnboundMethod (and any future boxed value) freeze via state.
if b, ok := self.(boxed); ok {
Expand Down Expand Up @@ -11250,6 +11258,85 @@ func (vm *VM) checkArrayFrozen(a *object.Array) {
}
}

// checkDefineFrozen refuses a method definition into a frozen definee, which is
// MRI's rb_class_modify_check (eval.c) on the method-definition path. rbgo had
// the frozen check on every REFLECTIVE definer -- define_method, attr_accessor,
// remove_method, const_set -- and not on `def` itself, so the one way everybody
// actually monkey-patches went through a freeze untouched (#798).
//
// For a singleton class the flag that matters belongs to the thing it stands
// for, and MRI names that thing rather than the anonymous singleton. rbgo
// records it in two different fields depending on which kind of singleton it
// is, and missing either one leaves a live bypass:
//
// attached a per-object singleton's object -- `def o.foo` on a frozen o
// metaOf a class's metaclass -- `class << c; def m; end; end`
//
// Measured on 4.0.7: the first says "can't modify frozen Object: #<Object:0x..>",
// the second "can't modify frozen Class: #<Class:0x..>". A first version of this
// helper handled only `attached`, and `class << frozen_class` went straight
// through it.
func (vm *VM) checkDefineFrozen(definee *RClass) {
target := object.Value(definee)
switch {
case definee.attached != nil:
target = definee.attached
case definee.metaOf != nil:
target = definee.metaOf
}
if explicitlyFrozen(target) {
vm.raiseFrozen(target)
}
}

// explicitlyFrozen is isFrozen minus the values that are frozen BY NATURE.
// isFrozen answers Ruby's `frozen?`, where an Integer, a Symbol and nil all say
// true because they are immutable -- and a definition guard built on it refuses
// `def nil.foo`, which MRI ALLOWS (nil/true/false take singleton methods on
// NilClass/TrueClass/FalseClass; measured on 4.0.7, and ruby/spec asserts it in
// core/{nil,true,false}/singleton_method_spec.rb). The first version of this
// guard used isFrozen and cost exactly those three files.
//
// What a definition guard needs is "was this frozen by someone", which is a
// flag, so this reads the flag and nothing else. The values that cannot carry
// one are not refused here at all: whether they may have a singleton class is
// ensureSingleton's question, and MRI answers it with TypeError.
func explicitlyFrozen(v object.Value) bool {
switch x := v.(type) {
case *object.String:
return x.Frozen
case *object.Array:
return x.Frozen
case *object.Hash:
return x.Frozen
case *Regexp:
return x.frozen
case *RObject:
return x.frozen
case *RClass:
return x.frozen
}
if b, ok := v.(boxed); ok {
return b.state().frozen
}
return false
}

// checkSingletonDefineFrozen refuses `def recv.name` / define_singleton_method
// on a frozen receiver. It is the receiver-side counterpart of
// checkDefineFrozen: here the object is in hand and no singleton class need be
// consulted.
//
// Callers run it AFTER the can-this-have-a-singleton test, not before: every
// immediate reports frozen, and MRI answers those with TypeError. Measured on
// 4.0.7, `1.define_singleton_method(:f){}` raises TypeError "can't define
// singleton" while a frozen Object raises FrozenError.
func (vm *VM) checkSingletonDefineFrozen(recv object.Value) {
if explicitlyFrozen(recv) {
vm.raiseFrozen(recv)
}
}

// checkHashFrozen is the Hash counterpart of checkArrayFrozen.
func (vm *VM) checkHashFrozen(h *object.Hash) {
if h.Frozen {
Expand Down
100 changes: 100 additions & 0 deletions internal/vm/frozen_define_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package vm_test

import "testing"

// TestFreezeStopsDef: freeze is a hardening boundary -- a library freezes a
// module to say "do not monkey-patch me after boot" -- and rbgo refused every
// REFLECTIVE definer (define_method, attr_accessor, remove_method, const_set)
// while letting the `def` keyword straight through (#798). A test of the
// protection written with define_method therefore passed while the protection
// was absent, which is why these cases are written with `def`.
//
// Every expectation here was measured against MRI
// `ruby 4.0.7 (2026-09-15 revision 229531a6cf) +PRISM [arm64-darwin25]`.
func TestFreezeStopsDef(t *testing.T) {
checkCases(t, []runCase{
// def on a frozen object's singleton: MRI names the OBJECT, not the
// anonymous singleton class.
{`o = Object.new.freeze
begin; o.instance_eval("def foo; end"); rescue FrozenError => e; puts e.message.split(":").first; end`,
"can't modify frozen Object\n"},
{`o = Object.new.freeze
begin; o.define_singleton_method(:foo) {}; rescue FrozenError => e; puts e.message.split(":").first; end`,
"can't modify frozen Object\n"},
// def into a frozen class, through the class body.
{`c = Class.new.freeze
begin; c.class_eval { def z; end }; rescue FrozenError => e; puts e.message.split(":").first; end`,
"can't modify frozen Class\n"},
// `class << c` reaches the metaclass, whose own frozen flag is false: the
// flag that counts is the class's, through metaOf. A first version of the
// fix handled only the per-object `attached` field and this went through.
{`c = Class.new.freeze
begin; class << c; def bar; end; end; rescue FrozenError => e; puts e.message.split(":").first; end`,
"can't modify frozen Class\n"},
// def self.foo on a frozen class.
{`c = Class.new.freeze
begin; c.instance_eval("def self.foo; end"); rescue FrozenError => e; puts e.message.split(":").first; end`,
"can't modify frozen Class\n"},
// A frozen module reports "Module", which is MRI 4.0's capitalised
// wording (3.x said "module"; ruby/spec carries the distinction as
// `ruby_version_is("4.0") ? "Module" : "module"`).
{`m = Module.new.freeze
begin; m.module_eval { def z; end }; rescue FrozenError => e; puts e.message.split(":").first; end`,
"can't modify frozen Module\n"},
// The precedence that makes the check's PLACEMENT matter: every immediate
// reports frozen, and MRI answers those with TypeError, so the frozen test
// has to run after the can-this-have-a-singleton test, not before.
{`begin; 1.define_singleton_method(:f) {}; rescue => e; p e.class; end`, "TypeError\n"},
{`begin; :s.define_singleton_method(:f) {}; rescue => e; p e.class; end`, "TypeError\n"},
// nil, true and false report frozen? == true -- they are immutable -- and
// MRI nonetheless ALLOWS a singleton method on them: it lands on
// NilClass/TrueClass/FalseClass. A guard built on Ruby's `frozen?` refuses
// these, and the first version of this one did: it cost
// core/{nil,true,false}/singleton_method_spec.rb, three files the
// conformance ratchet caught at 0 passing against a baseline of 1. What a
// definition guard needs is "frozen by SOMEONE", which is a flag, not
// "frozen by nature", which is a type.
{`nil.define_singleton_method(:zz) { 42 }
p nil.zz`, "42\n"},
{`true.define_singleton_method(:zz) { 42 }
p true.zz`, "42\n"},
{`def (false).zz; 42; end
p false.zz`, "42\n"},
// Unfrozen definition still works -- a check that refused everything would
// also pass every case above.
{`c = Class.new
c.class_eval { def z; 7; end }
p c.new.z`, "7\n"},
{`o = Object.new
def o.foo; 8; end
p o.foo`, "8\n"},
})
}

// TestAFrozenRegexpRefusesDefToo covers the one branch of explicitlyFrozen the
// first version of this change left untested -- the coverage gate named it, at
// 90.0%, and the question a named function asks is whether the branch is DEAD
// or merely unreached. It was unreached: a literal /a/ is born frozen in both
// engines, and defining on it already raised.
//
// Measuring it turned up a second thing. `Regexp.new("a").freeze.frozen?`
// answered FALSE: Object#freeze had no *Regexp arm while isFrozen had read
// Regexp.frozen all along, so the two disagreed. The literal case hid it,
// because a literal needs no freeze call to be frozen.
func TestAFrozenRegexpRefusesDefToo(t *testing.T) {
checkCases(t, []runCase{
// freeze must actually mark it. This is the half that was broken.
{`r = Regexp.new("a"); p r.frozen?; r.freeze; p r.frozen?`, "false\ntrue\n"},
// ... and then refuse a definition, naming the Regexp as MRI does.
{`r = Regexp.new("a").freeze
begin; r.define_singleton_method(:z) {}; rescue FrozenError => e; puts e.message.split(":").first; end`,
"can't modify frozen Regexp\n"},
// A literal is born frozen in both engines, so it refuses without a
// freeze call -- this is the case that was already passing and hid the
// one above.
{`begin; /a/.define_singleton_method(:z) {}; rescue FrozenError => e; puts e.message.split(":").first; end`,
"can't modify frozen Regexp\n"},
// The negative control: an unfrozen Regexp still takes a singleton method.
{`r = Regexp.new("a"); r.define_singleton_method(:z) { 7 }; p r.z`, "7\n"},
})
}
2 changes: 2 additions & 0 deletions internal/vm/object_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ func (vm *VM) singletonClass(o *RObject) *RClass {
// lands on main's singleton class — matching MRI — rather than on the definee.
func (vm *VM) defineSingletonMethod(recv object.Value, name string, iseq *bytecode.ISeq) {
if t, ok := recv.(*RClass); ok {
vm.checkSingletonDefineFrozen(t)
t.smethods[name] = &Method{name: name, iseq: iseq, owner: t}
bumpMethodSerial()
vm.fireSingletonMethodHook(recv, "singleton_method_added", name)
Expand All @@ -538,6 +539,7 @@ func (vm *VM) defineSingletonMethod(recv object.Value, name string, iseq *byteco
if !ok {
raise("TypeError", "can't define singleton method %q for %s", name, vm.classOf(recv).name)
}
vm.checkSingletonDefineFrozen(recv)
sc.methods[name] = &Method{name: name, iseq: iseq, owner: sc}
bumpMethodSerial() // adding a singleton method can change what a cached send resolves to
vm.fireSingletonMethodHook(recv, "singleton_method_added", name)
Expand Down
2 changes: 2 additions & 0 deletions internal/vm/singleton.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func (vm *VM) registerSingleton() {
}
name := args[0].ToS()
if t, ok := self.(*RClass); ok {
vm.checkSingletonDefineFrozen(t)
t.smethods[name] = &Method{name: name, proc: body, owner: t}
bumpMethodSerial()
vm.fireSingletonMethodHook(self, "singleton_method_added", name)
Expand All @@ -53,6 +54,7 @@ func (vm *VM) registerSingleton() {
if !ok {
raise("TypeError", "can't define singleton method %q for %s", name, vm.classOf(self).name)
}
vm.checkSingletonDefineFrozen(self)
sc.methods[name] = &Method{name: name, proc: body, owner: sc}
bumpMethodSerial()
vm.fireSingletonMethodHook(self, "singleton_method_added", name)
Expand Down
2 changes: 2 additions & 0 deletions internal/vm/vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -2754,6 +2754,8 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value,
push(vm.dispatchSend(recv, bname, callArgs, bblk))
case bytecode.OpDefineMethod:
name := iseq.Names[in.A]
// Before anything is written: a frozen definee refuses the def.
vm.checkDefineFrozen(methodDefinee)
// methodDefinee is the definee, except under instance_eval/instance_exec
// where the def lands on the receiver's singleton class instead.
m := &Method{name: name, iseq: iseq.Children[in.B], owner: methodDefinee, vis: methodDefinee.defaultVis}
Expand Down
Loading
Loading