diff --git a/README.md b/README.md index 8424f8b7..7ed1ee46 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 9673ee7f..a3483f32 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -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 { @@ -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: #", +// the second "can't modify frozen Class: #". 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 { diff --git a/internal/vm/frozen_define_test.go b/internal/vm/frozen_define_test.go new file mode 100644 index 00000000..0ef6c6b7 --- /dev/null +++ b/internal/vm/frozen_define_test.go @@ -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"}, + }) +} diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index e486495f..f53b693a 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -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) @@ -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) diff --git a/internal/vm/singleton.go b/internal/vm/singleton.go index 5aae55b2..f8fca7b6 100644 --- a/internal/vm/singleton.go +++ b/internal/vm/singleton.go @@ -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) @@ -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) diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 7f77f377..7dd7eb4c 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -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} diff --git a/scripts/conformance/rubyspec/BASELINE b/scripts/conformance/rubyspec/BASELINE index be618a7a..61ecb187 100644 --- a/scripts/conformance/rubyspec/BASELINE +++ b/scripts/conformance/rubyspec/BASELINE @@ -5,6 +5,19 @@ # Recorded 2026-09-28 on main @862a9f3, corpus SPEC_SHA # 87b1631992bd00cf0c4934474766d54dad088191, 2206 language/+core/ *_spec.rb. # +# Updated 2026-10-08 for the frozen-def fix (#800), 23483 -> 23512 over the 20 +# files the ratchet reported improved. The twenty deltas were taken from the +# CI run on 496c39d (ubuntu-latest, TIMEOUT=60) and applied to this file, +# rather than re-measured here: this machine was carrying a desktop load of 12 +# and a pass/fail count is corrupted under load through the per-file timeout -- +# which is what "record it on an unloaded machine" above is about, and the +# reason the CI job raises TIMEOUT. Three checks before writing: the pre-edit +# sum matched the 23483 the README claimed, every one of CI's "baseline" +# columns matched the value recorded here, and the post-edit sum equals the +# 23512 CI reported. CI's verdict listed improved (20) and nothing else -- no +# regression, no new file, no change of loading -- so the deltas are the whole +# move. +# # THE FRAME MATTERS, twice over, and both are why an earlier attempt at this # file was wrong: # @@ -150,7 +163,7 @@ OK core/array/max_spec.rb 12 OK core/array/min_spec.rb 12 OK core/array/minmax_spec.rb 5 OK core/array/minus_spec.rb 10 -OK core/array/multiply_spec.rb 13 +OK core/array/multiply_spec.rb 14 OK core/array/new_spec.rb 20 OK core/array/none_spec.rb 2 OK core/array/one_spec.rb 2 @@ -590,7 +603,7 @@ OK core/exception/equal_value_spec.rb 10 OK core/exception/errno_spec.rb 8 OK core/exception/exception_spec.rb 10 OK core/exception/exit_value_spec.rb 1 -OK core/exception/frozen_error_spec.rb 0 +OK core/exception/frozen_error_spec.rb 1 OK core/exception/full_message_spec.rb 8 OK core/exception/hierarchy_spec.rb 0 OK core/exception/inspect_spec.rb 5 @@ -801,7 +814,7 @@ OK core/float/gt_spec.rb 6 OK core/float/gte_spec.rb 6 OK core/float/hash_spec.rb 2 OK core/float/infinite_spec.rb 4 -OK core/float/inspect_spec.rb 0 +OK core/float/inspect_spec.rb 1 OK core/float/lt_spec.rb 6 OK core/float/lte_spec.rb 6 OK core/float/magnitude_spec.rb 1 @@ -1113,7 +1126,7 @@ OK core/kernel/chop_spec.rb 0 OK core/kernel/class_spec.rb 3 OK core/kernel/clone_spec.rb 30 OK core/kernel/comparison_spec.rb 5 -OK core/kernel/define_singleton_method_spec.rb 7 +OK core/kernel/define_singleton_method_spec.rb 8 OK core/kernel/display_spec.rb 0 OK core/kernel/dup_spec.rb 17 OK core/kernel/enum_for_spec.rb 0 @@ -1278,7 +1291,7 @@ OK core/math/sqrt_spec.rb 8 OK core/math/tan_spec.rb 8 OK core/math/tanh_spec.rb 7 OK core/method/arity_spec.rb 22 -OK core/method/call_spec.rb 5 +OK core/method/call_spec.rb 6 OK core/method/case_compare_spec.rb 1 OK core/method/clone_spec.rb 3 OK core/method/compose_spec.rb 14 @@ -1292,14 +1305,14 @@ OK core/method/inspect_spec.rb 1 OK core/method/name_spec.rb 3 OK core/method/original_name_spec.rb 8 OK core/method/owner_spec.rb 4 -OK core/method/parameters_spec.rb 51 +OK core/method/parameters_spec.rb 52 OK core/method/private_spec.rb 1 OK core/method/protected_spec.rb 1 OK core/method/public_spec.rb 1 OK core/method/receiver_spec.rb 3 OK core/method/source_location_spec.rb 12 OK core/method/super_method_spec.rb 6 -OK core/method/to_proc_spec.rb 8 +OK core/method/to_proc_spec.rb 9 OK core/method/to_s_spec.rb 15 OK core/method/unbind_spec.rb 6 OK core/module/alias_method_spec.rb 22 @@ -1431,7 +1444,7 @@ OK core/numeric/fdiv_spec.rb 6 OK core/numeric/finite_spec.rb 0 OK core/numeric/floor_spec.rb 1 OK core/numeric/i_spec.rb 3 -OK core/numeric/imag_spec.rb 0 +OK core/numeric/imag_spec.rb 1 OK core/numeric/imaginary_spec.rb 2 OK core/numeric/infinite_spec.rb 0 OK core/numeric/integer_spec.rb 1 @@ -1446,7 +1459,7 @@ OK core/numeric/polar_spec.rb 4 OK core/numeric/positive_spec.rb 5 OK core/numeric/quo_spec.rb 10 OK core/numeric/real_spec.rb 3 -OK core/numeric/rect_spec.rb 0 +OK core/numeric/rect_spec.rb 1 OK core/numeric/rectangular_spec.rb 5 OK core/numeric/remainder_spec.rb 5 OK core/numeric/round_spec.rb 1 @@ -1617,7 +1630,7 @@ OK core/queue/pop_spec.rb 20 OK core/queue/push_spec.rb 1 OK core/queue/shift_spec.rb 1 OK core/queue/size_spec.rb 1 -OK core/random/bytes_spec.rb 8 +OK core/random/bytes_spec.rb 9 OK core/random/default_spec.rb 1 OK core/random/equal_value_spec.rb 2 OK core/random/new_seed_spec.rb 3 @@ -1828,7 +1841,7 @@ OK core/string/comparison_spec.rb 19 OK core/string/concat_spec.rb 29 OK core/string/count_spec.rb 8 OK core/string/crypt_spec.rb 0 -OK core/string/dedup_spec.rb 0 +OK core/string/dedup_spec.rb 1 OK core/string/delete_prefix_spec.rb 12 OK core/string/delete_spec.rb 13 OK core/string/delete_suffix_spec.rb 12 @@ -2018,7 +2031,7 @@ OK core/thread/backtrace/location/to_s_spec.rb 1 OK core/thread/backtrace_locations_spec.rb 18 OK core/thread/backtrace_spec.rb 11 OK core/thread/current_spec.rb 3 -OK core/thread/each_caller_location_spec.rb 5 +OK core/thread/each_caller_location_spec.rb 6 OK core/thread/element_reference_spec.rb 5 OK core/thread/element_set_spec.rb 5 OK core/thread/exit_spec.rb 2 @@ -2161,7 +2174,7 @@ OK core/unboundmethod/bind_spec.rb 8 OK core/unboundmethod/clone_spec.rb 3 OK core/unboundmethod/dup_spec.rb 3 OK core/unboundmethod/eql_spec.rb 1 -OK core/unboundmethod/equal_value_spec.rb 15 +OK core/unboundmethod/equal_value_spec.rb 17 OK core/unboundmethod/hash_spec.rb 3 OK core/unboundmethod/inspect_spec.rb 1 OK core/unboundmethod/name_spec.rb 2 @@ -2192,7 +2205,7 @@ OK language/class_spec.rb 40 OK language/class_variable_spec.rb 13 OK language/comment_spec.rb 1 OK language/constants_spec.rb 88 -OK language/def_spec.rb 61 +OK language/def_spec.rb 63 OK language/defined_spec.rb 256 OK language/delegation_spec.rb 12 OK language/encoding_spec.rb 5 @@ -2204,14 +2217,14 @@ OK language/hash_spec.rb 29 OK language/heredoc_spec.rb 10 OK language/if_spec.rb 50 OK language/it_parameter_spec.rb 15 -OK language/keyword_arguments_spec.rb 14 +OK language/keyword_arguments_spec.rb 20 OK language/lambda_spec.rb 56 OK language/line_spec.rb 6 OK language/loop_spec.rb 5 OK language/magic_comment_spec.rb 27 OK language/match_spec.rb 8 OK language/metaclass_spec.rb 21 -OK language/method_spec.rb 157 +OK language/method_spec.rb 160 OK language/module_spec.rb 14 OK language/next_spec.rb 31 OK language/not_spec.rb 9 @@ -2221,7 +2234,7 @@ OK language/optional_assignments_spec.rb 74 OK language/or_spec.rb 12 OK language/order_spec.rb 5 OK language/pattern_matching_spec.rb 103 -OK language/precedence_spec.rb 29 +OK language/precedence_spec.rb 30 OK language/predefined/data_spec.rb 0 OK language/predefined/toplevel_binding_spec.rb 0 OK language/predefined_spec.rb 141 @@ -2241,15 +2254,15 @@ OK language/regexp/modifiers_spec.rb 6 OK language/regexp/repetition_spec.rb 11 OK language/regexp/subexpression_call_spec.rb 3 OK language/regexp_spec.rb 18 -OK language/rescue_spec.rb 52 +OK language/rescue_spec.rb 53 OK language/retry_spec.rb 3 OK language/return_spec.rb 37 OK language/safe_navigator_spec.rb 9 OK language/safe_spec.rb 1 -OK language/send_spec.rb 71 +OK language/send_spec.rb 72 OK language/singleton_class_spec.rb 47 OK language/source_encoding_spec.rb 0 -OK language/string_spec.rb 24 +OK language/string_spec.rb 25 OK language/super_spec.rb 58 OK language/symbol_spec.rb 11 OK language/throw_spec.rb 9