From 49c5c5dd84572eac7fbad5d414ae6c636cc23dda Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 27 Sep 2026 16:48:58 +0200 Subject: [PATCH 1/5] internal/vm: a frame carries its scope, so a native can reach the caller's (#715 hand-over) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vm.frameCode recorded only (iseq, pc). The (env, self, definee) triple lived in exec's LOCALS, so a native method — which pushes no frame of its own — had no way to obtain the calling scope. MRI keeps all five side by side on rb_control_frame_t (vm_core.h ruby_4_0:915-926), which is what lets Kernel#binding be an ordinary method there: rb_f_binding -> rb_binding_new -> rb_vm_make_binding(ec, ec->cfp) (proc.c ruby_4_0:328-333, 379-383, registered by rb_define_global_function at proc.c:4726), with rb_vm_get_binding_creatable_next_cfp walking past the CFUNC frame. frameCode now carries env/self/definee too, and frameBinding(i) builds a Binding from them — with the LINE taken from the frame's live pc, which the interpreter already publishes on every instruction. Measured against ruby 4.0.5, before -> after: eval(str, binding) 42 -> 42 eval(str) 42 -> 42 (was the ONLY shape that worked, via a compiler rewrite to eval(str, binding); the rewrite is now gone) send(:eval, str) NoMethodError -> 42 alias ev eval; ev(str) NoMethodError -> 42 method(:eval).call(str) NoMethodError -> 42 eval(str, nil) / 4-arg eval NoMethodError -> 7 5.send(:eval, "[self, x]") NoMethodError -> [5, 1] send(:binding) NoMethodError -> Binding obj.binding NoMethodError -> NoMethodError (private) binding.source_location ["f.rb", 0] -> ["f.rb", 18] TOPLEVEL_BINDING.source_location nil -> ["
", 0] b.eval("__method__") nil -> :q Kernel#eval takes only the LOCAL SCOPE from the caller here; the definee still comes from evalDefinee(self), so nothing else about it moves. MRI takes the definee from the caller's cref, which the frame now carries — a separate move. Co-Authored-By: Claude Opus 5 --- internal/compiler/compiler.go | 17 +++---- internal/vm/binding.go | 64 ++++++++++++++++++++---- internal/vm/binding_eval_open.go | 12 +++-- internal/vm/eval.go | 28 ++++++++--- internal/vm/vm.go | 86 ++++++++++++++++++++++++++++++-- 5 files changed, 172 insertions(+), 35 deletions(-) diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 771b8f8a..822ca943 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -1297,16 +1297,13 @@ func (c *Compiler) compileCall(v *ast.Call) { }) return } - // A bare eval(str) with no explicit binding evaluates against the caller's - // binding, so it can see (and assign) the caller's local variables — exactly - // like eval(str, binding). Rewrite it to that two-argument form using the - // binding intrinsic; the added `binding` argument compiles to OpBinding above. - if v.Recv == nil && v.Block == nil && v.Name == "eval" && len(v.Args) == 1 { - v2 := *v - v2.Args = []ast.Node{v.Args[0], &ast.Call{Name: "binding"}} - c.compileCall(&v2) - return - } + // NO rewrite of `eval(str)` into `eval(str, binding)` here any more. It existed + // because Kernel#eval, a native, could not reach the caller's local scope — and + // it only ever covered this ONE shape: #send, an alias, a Method object and + // every other arity still ran against an empty scope. Kernel#eval now reads the + // calling frame directly (vm.callerBinding), which is where MRI takes it from + // (rb_f_eval -> eval_string_with_cref on the calling cfp), so the rewrite would + // be a second door onto the same rule. // A bare, zero-arg name that resolves to a local is a variable read, not a // send. The parser emits a Call (not a VarRef) for an identifier whose // assignment it never saw — which is exactly how an eval'd string references diff --git a/internal/vm/binding.go b/internal/vm/binding.go index f4cca4a7..e6b0e42f 100644 --- a/internal/vm/binding.go +++ b/internal/vm/binding.go @@ -11,8 +11,29 @@ type Binding struct { self object.Value definee *RClass file string // source file the binding was captured in ("" for compiled-in code) + line int // 1-based line the binding was captured at (MRI's bind->first_lineno) names []string // slot index → local name (original ISeq locals, then injected ones) added []string // names injected via local_variable_set/eval, in insertion order + + // method is the __method__/__callee__ pair of the frame the binding was + // captured in, so eval'd code reports the enclosing method. MRI reaches the + // same answer from the binding's own frame (rb_vm_frame_method_entry on the + // cfp the binding pins); rbgo records the pair the frame already carries. + method frameMethod +} + +// toplevelBindingFile is the path MRI reports for TOPLEVEL_BINDING: the binding +// is made before any script frame exists, so bind_location has no real path and +// pathobj_path yields the "
" label. Measured on ruby 4.0.5 for both a +// script and -e: TOPLEVEL_BINDING.source_location == ["
", 0]. +const toplevelBindingFile = "
" + +// newToplevelBinding builds a Binding for the top level: its self is main and its +// definee is Object, so TOPLEVEL_BINDING.eval("def m; end" / "private :m") +// defines and sets visibility on Object exactly as top-level code does. It starts +// with no locals of its own. +func (vm *VM) newToplevelBinding() *Binding { + return &Binding{env: &Env{}, self: vm.main, definee: vm.cObject, file: toplevelBindingFile} } func (b *Binding) ToS() string { return "#" } @@ -33,11 +54,31 @@ func (vm *VM) registerBinding() { cBinding := newClass("Binding", vm.cObject) vm.consts["Binding"] = cBinding - // TOPLEVEL_BINDING is a Binding for the top level: its self is main and its - // definee is Object, so TOPLEVEL_BINDING.eval("def m; end" / "private :m") - // defines and sets visibility on Object exactly as top-level code does. It - // starts with no locals of its own. - vm.consts["TOPLEVEL_BINDING"] = &Binding{env: &Env{}, self: vm.main, definee: vm.cObject} + vm.consts["TOPLEVEL_BINDING"] = vm.newToplevelBinding() + + // Kernel#binding is a METHOD in MRI, not an instruction: rb_f_binding is + // installed by rb_define_global_function("binding", rb_f_binding, 0) (proc.c + // ruby_4_0:4726) and its body is rb_binding_new() -> rb_vm_make_binding(ec, + // ec->cfp) (proc.c ruby_4_0:328-333). A CFUNC frame cannot make a binding, so + // rb_vm_get_binding_creatable_next_cfp walks past it to the calling Ruby frame + // — which in rbgo is simply the top of the frame stack, since a native pushes + // no frame. Being a method is what makes obj.send(:binding) work; it used to + // raise NoMethodError because the only way to reach a Binding was the bareword + // intrinsic the compiler lowers to OpBinding. + // + // rb_define_global_function is the module-function form, so the Object-side + // copy is PRIVATE: `obj.binding` raises "private method 'binding' called for + // …" on ruby 4.0.5, and marking it here is what reproduces that. + cObject := vm.cObject + cObject.define("binding", func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) != 0 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 0)", len(args)) + } + return vm.callerBinding() + }) + if m := cObject.methods["binding"]; m != nil { + m.vis = visPrivate + } // Binding#eval(src [, file [, line]]) is bind_eval (proc.c ruby_4_0:402): it // scans "12" and then calls rb_f_eval with the binding inserted as the scope, @@ -66,15 +107,18 @@ func (vm *VM) registerBinding() { }) // source_location reports [file, line] for where the binding was captured, or // nil when no source is known (compiled-in code such as the prelude, whose - // ISeq carries no File). As with Proc#source_location the VM does not track - // per-instruction line numbers, so the line is reported as 0 — the array shape - // ([String, Integer]) is what callers depend on. + // ISeq carries no File). This is bind_location (proc.c ruby_4_0:805-815): a + // pure read of the two capture-time fields, pathobj_path(bind->pathobj) and + // INT2FIX(bind->first_lineno) — no stack walk, so it keeps reporting the + // CREATION site however far the frame has since run on. rbgo reported 0 for the + // line because the Binding recorded none; the frame's live pc now supplies it + // (frameBinding), the same pc a backtrace resolves. cBinding.define("source_location", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { b := self.(*Binding) if b.file == "" { return object.NilV } - return object.NewArray(object.NewString(b.file), object.IntValue(0)) + return object.NewArray(object.NewString(b.file), object.IntValue(int64(b.line))) }) // dup / clone return a shallow copy: the environment is shared (so a write to // an existing local through one copy is visible through the other, as MRI @@ -87,6 +131,8 @@ func (vm *VM) registerBinding() { self: b.self, definee: b.definee, file: b.file, + line: b.line, + method: b.method, names: append([]string(nil), b.names...), added: append([]string(nil), b.added...), } diff --git a/internal/vm/binding_eval_open.go b/internal/vm/binding_eval_open.go index bca8790d..7f667f37 100644 --- a/internal/vm/binding_eval_open.go +++ b/internal/vm/binding_eval_open.go @@ -45,11 +45,13 @@ func (vm *VM) bindingEval(b *Binding, src string, loc evalLocation) object.Value // line) — stamp the compiled ISeq, and the binding's own source location is // left untouched (bind_location reads the binding, not the eval). setISeqFile(iseq, loc.file) - // eval is transparent to Kernel#__method__ / #__callee__: the evaluated code - // inherits the caller's method context (so `eval("__method__")`, which routes - // through the caller's binding, reports the enclosing method), matching the - // no-binding eval path. - vm.pendingMethodCtx = vm.currentMethodCtxPtr() + // eval is transparent to Kernel#__method__ / #__callee__, and through a BINDING + // the answer is the binding's own frame, not the frame doing the eval: MRI reads + // the method entry of the cfp the binding pins, so a Binding captured inside `q` + // and eval'd from the top level reports :q (measured on ruby 4.0.5). The pair + // the capturing frame recorded rides on the Binding for exactly this. + mc := b.method + vm.pendingMethodCtx = &mc return vm.exec(iseq, b.self, nil, b.definee, "", b.env, nil, nil, nil, nil) } diff --git a/internal/vm/eval.go b/internal/vm/eval.go index e3f15f0d..130d755a 100644 --- a/internal/vm/eval.go +++ b/internal/vm/eval.go @@ -219,14 +219,26 @@ func (vm *VM) registerEval() { } return vm.bindingEval(b, src, loc) } - // Run against the caller's self/definee with a fresh local scope; a runtime - // RubyError from the evaluated code propagates (and is rescuable) as usual. - // eval is transparent to Kernel#__method__ / #__callee__: the evaluated code - // inherits the caller's method context (eval "__method__" inside a method - // reports that method), so hand exec the caller's pair. - iseq := vm.compileEval(src, loc) - vm.pendingMethodCtx = vm.currentMethodCtxPtr() - return vm.exec(iseq, self, nil, vm.evalDefinee(self), "", nil, nil, nil, nil, nil) + // No scope argument: MRI still evaluates against the CALLER'S LOCAL SCOPE — + // rb_f_eval calls eval_string_with_cref(self, ...) on the calling cfp, so + // `def m; x = 41; eval("x + 1"); end` is 42 on ruby 4.0.5. The self is the + // RECEIVER, not the frame's: `5.send(:eval, "[self, x]")` measures [5, 1] + // there, so the two halves come from different places. + // + // rbgo used to reach this only through a COMPILER REWRITE of the bareword + // `eval(str)` into `eval(str, binding)`, which meant #send, an alias, a + // Method object and every other arity ran with a fresh, empty local scope. + // Reading the caller's frame here covers all of them at once, and the rewrite + // is gone. + // + // Only the LOCAL SCOPE is taken from the caller here: the definee keeps + // coming from evalDefinee(self) below, so this change adds the locals and + // alters nothing else. (MRI takes the definee from the caller's CREF too, + // which the frame now carries — a separate move, measured separately.) + cb := vm.callerBinding() + cb.self = self + cb.definee = vm.evalDefinee(self) + return vm.bindingEval(cb, src, loc) }) } diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 409b7f7c..3457916a 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -224,11 +224,81 @@ func (vm *VM) frameLine(i int) int { return fc.iseq.FirstLine } -// frameCode is one frame's code position: the ISeq it runs and the instruction -// it has reached. It is MRI's (cfp->iseq, cfp->pc). +// frameCode is one frame's code position AND scope: the ISeq it runs, the +// instruction it has reached, and the (env, self, definee) triple it runs under. +// The first pair is MRI's (cfp->iseq, cfp->pc); the second is MRI's (cfp->ep, +// cfp->self, the cref reached through cfp->ep[VM_ENV_DATA_INDEX_ME_CREF]) — +// rb_control_frame_t carries all five side by side (vm_core.h ruby_4_0:915-926). +// +// The triple used to live in exec's LOCALS, and that is the whole of why a native +// method could not reach the calling scope: rbgo pushes no frame for a native, so +// Kernel#binding — rb_f_binding -> rb_binding_new -> rb_vm_make_binding(ec, +// ec->cfp) (proc.c ruby_4_0:328-333, 379-383) — had nothing to read. The +// compiler papered over the single shape `eval(str)` by rewriting it to +// `eval(str, binding)`; every other shape (#send, an alias, a Method object, any +// other arity) missed the caller's locals, and Kernel#binding could not be a +// method at all. type frameCode struct { iseq *bytecode.ISeq pc int + + // env is the frame's local-variable environment (MRI's cfp->ep). It is nil + // only before exec has published it, which is what frameBinding skips on: + // MRI's rb_vm_get_binding_creatable_next_cfp (vm_core.h ruby_4_0:1993) walks + // outward for the same reason, past frames whose env cannot be escaped. + env *Env + self object.Value + definee *RClass +} + +// frameBinding builds a Binding for the innermost frame at or below index i that +// can make one, or nil when no frame can. This is rb_vm_make_binding after +// rb_vm_get_binding_creatable_next_cfp: rbgo pushes a frame only for a Ruby-level +// ISeq (a native pushes none), so the walk only has to skip a frame whose scope +// exec has not published yet. +// +// The line comes from frameLine, i.e. from the frame's LIVE pc, which the +// interpreter publishes on every instruction — so Binding#source_location reports +// the capture line the way bind_location reads bind->first_lineno (proc.c +// ruby_4_0:805-815) rather than the 0 rbgo reported for want of the number. +func (vm *VM) frameBinding(i int) *Binding { + if i >= len(vm.frameCode) { + i = len(vm.frameCode) - 1 + } + for ; i >= 0; i-- { + fc := vm.frameCode[i] + if fc.env == nil || fc.iseq == nil { + continue + } + b := &Binding{ + env: fc.env, + self: fc.self, + definee: fc.definee, + file: fc.iseq.File, + line: vm.frameLine(i), + names: append([]string(nil), fc.iseq.Locals...), + } + // A Binding is transparent to Kernel#__method__ / #__callee__: code eval'd + // through it reports the method the binding was captured in, which is the + // pair that frame recorded. + if i < len(vm.frameMethods) { + b.method = vm.frameMethods[i] + } + return b + } + // No frame has published a scope — a native reached from Go before Run, or + // after the Run boundary reset the stacks. The only scope that exists then is + // the top level's, which is what TOPLEVEL_BINDING is; returning it keeps every + // caller free of a nil case it could not otherwise exercise. + return vm.newToplevelBinding() +} + +// callerBinding is the Binding a NATIVE method's caller would get from +// Kernel#binding. A native pushes no frame, so the top of the frame stack IS the +// calling Ruby frame — the cfp rb_vm_get_binding_creatable_next_cfp lands on once +// it has skipped the CFUNC frame. +func (vm *VM) callerBinding() *Binding { + return vm.frameBinding(len(vm.frameNames) - 1) } func (vm *VM) uncaughtBacktrace(e RubyError) []object.Value { @@ -1559,6 +1629,14 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // instruction it has reached, which is what a backtrace resolves to a line. myFrame := len(vm.frameNames) - 1 vm.setFrameCode(myFrame, iseq) + // Publish this frame's SCOPE beside its code position, so a native method — + // which pushes no frame of its own — can read the calling env/self/definee off + // the top of the stack (frameBinding). definee is the frame's, not + // methodDefinee's: a Binding captures the scope `def` and `CONST =` target, + // which is what MRI's cref slot holds. + vm.frameCode[myFrame].env = env + vm.frameCode[myFrame].self = self + vm.frameCode[myFrame].definee = definee // frameCrefs mirrors frameNames too; the cref is filled in below once lexCref // is known (a nil placeholder keeps the stacks aligned until then). vm.frameCrefs = append(vm.frameCrefs, nil) @@ -2586,7 +2664,9 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, push(vm.runDefinedGuard(iseq.Children[in.A], self, definee, env, block)) case bytecode.OpBinding: markEnvCaptured(env) - push(&Binding{env: env, self: self, definee: definee, file: iseq.File, names: append([]string(nil), iseq.Locals...)}) + // One door: the bareword intrinsic and Kernel#binding both build the + // Binding from this frame's published scope, so the two cannot drift. + push(vm.frameBinding(myFrame)) case bytecode.OpArgGiven: push(object.Bool(in.A < nPosGiven)) case bytecode.OpKwGiven: From 1ddccb0236ed72c3c8b5ed45fd4762e7c2ac6718 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 27 Sep 2026 16:56:20 +0200 Subject: [PATCH 2/5] internal/vm: $! is per-rescue-CLAUSE state, and ::Const obeys private_constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $! was saved and restored once per exec FRAME, so a handled exception stayed "current" for the rest of the method. MRI's storage is per RESCUE CLAUSE and the restore is structural: reading $! walks the control frames out to the innermost ISEQ_TYPE_RESCUE frame and returns THAT frame's last local (errinfo_place, eval.c ruby_4_0:2009-2029), falling back to ec->errinfo only when no such frame is on the stack. When a clause completes, its frame is popped and $! reverts to the enclosing view — RESTORED to the value before the begin, not cleared to nil. The C mirror is rb_vrescue2's `ec->errinfo = e_info;` after a handler returns (eval.c ruby_4_0:1109). rbgo does not compile a rescue clause into its own ISeq, so the scope is made explicit: the lowering saves the errinfo cell into an anonymous local before the handler is armed and writes it back on every path that LEAVES a clause — the clause's own end, `retry`, and a `return` written in the clause. The one path the compiler cannot reach is `foo { return }`, where the return lives in another ISeq; the interpreter closes the scope there, when the unwind is about to leave the frame with no ensure left to run. An ensure body still inside the frame keeps the clause-exit value, which is what errinfo_place's THROW_DATA guard produces. $! is read-only to Ruby (rb_define_virtual_variable with a 0 setter, eval.c ruby_4_0:2225 — `$! = x` raises NameError on ruby 4.0.5 and in rbgo), so the lowering reads and writes the cell through bytecode.ErrinfoCell, the reserved name `#$!`. That is MRI's own name for the same hidden slot, and it is unspellable in Ruby source. Also: `::Name` is a QUALIFIED constant reference, so Module#private_constant applies to it even when the holder is Object. It was the one shape that could still read a private top-level constant, and defined?(::Name) reported "constant" for one. Measured against ruby 4.0.5, 17 hand-written witnesses now agree exactly, including the four that had been silently wrong in the other direction (a nested rescue reported the INNER exception where MRI reports the outer; `raise` with no argument after a successful rescue re-raised the handled exception). ruby/spec, base -> branch (same machine, back to back): language/predefined_spec.rb 130 -> 141 (shim ceiling 158; every $!/$@ example now passes) language/constants_spec.rb 85 -> 87 (shim ceiling 100) Co-Authored-By: Claude Opus 5 --- internal/bytecode/bytecode.go | 12 ++++++ internal/compiler/compiler.go | 69 +++++++++++++++++++++++++++++++++++ internal/vm/vm.go | 54 +++++++++++++++++++++++++-- 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/internal/bytecode/bytecode.go b/internal/bytecode/bytecode.go index 8b0990c5..6fbada4b 100644 --- a/internal/bytecode/bytecode.go +++ b/internal/bytecode/bytecode.go @@ -389,6 +389,18 @@ func (s *ISeq) LineAt(pc int) int { return s.Lines[lo].Line } +// ErrinfoCell is the reserved global-variable name the compiler uses to read and +// write the interpreter's errinfo cell — the value Ruby's `$!` reports — when it +// lowers a begin/rescue. It is deliberately UNSPELLABLE in Ruby source (`#` opens +// a comment), so no program can name it, and it is the name MRI gives the same +// hidden slot: the rescue ISeq's local table calls it `#$!`, which is why +// errinfo_place can read `$!` straight out of a frame (eval.c ruby_4_0:2009-2029). +// +// It exists because `$!` itself is READ-ONLY to Ruby (rb_define_virtual_variable +// with a 0 setter, eval.c ruby_4_0:2225 — `$! = x` raises NameError on ruby 4.0.5 +// and in rbgo), so the lowering cannot go through the user-visible name. +const ErrinfoCell = "#$!" + // HandlerState reports the memoised rescue-handler flag (0/1/2; see the field). func (s *ISeq) HandlerState() uint8 { return s.handlerState } diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 822ca943..1f2ad1fd 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -247,6 +247,13 @@ func (b *builder) build() *bytecode.ISeq { type Compiler struct { ctxs []*loopCtx // innermost-last stack of break/next targets retryTargets []int // innermost-last stack of begin-body PCs for `retry` + // errinfoSlots is the innermost-last stack of anonymous local slots holding the + // errinfo value saved on entry to each open begin/rescue, so every path that + // leaves a rescue clause can put it back (see compileBeginRescue). Each entry + // remembers the BUILDER that owns the slot: a slot index means nothing in + // another ISeq, so a `retry` written inside a block nested in a rescue clause + // must not read it. + errinfoSlots []errinfoScope stack []*builder // srcEnc is the source file's encoding (from a `# encoding:` magic comment), // canonicalised to a registry name, or "" for the UTF-8 default. When set, a @@ -787,6 +794,13 @@ func (c *Compiler) compileNode1(n ast.Node) { if len(c.retryTargets) == 0 { c.fail("Invalid retry") } + // `retry` LEAVES the rescue clause without reaching its end, so the errinfo + // scope has to be closed here too: MRI's retry path sets errinfo back to nil + // explicitly (rb_vrescue2, eval.c ruby_4_0:1080), and after a retry that + // succeeds `$!` reads as the value it had before the begin — measured on + // ruby 4.0.5. The jump target is the handler PUSH, which sits after the save, + // so the saved value survives the re-entry. + c.emitErrinfoRestore() b.emit(bytecode.OpJump, c.retryTargets[len(c.retryTargets)-1], 0) case *ast.OpAssign: // Allocate the slot before the read so a fresh `x ||= v` sees nil @@ -983,6 +997,12 @@ func (c *Compiler) compileNode1(n ast.Node) { } else { c.compileNode(v.Value) } + // A `return` out of a rescue clause LEAVES the clause, so its errinfo scope + // closes here too — in MRI the rescue frame is simply popped, and `$!` after + // a rescue that returned reads as the value it had before the begin (three + // ruby/spec examples measure exactly this). It goes AFTER the return value is + // evaluated, because `return $!` must still see the exception. + c.emitErrinfoRestore() // An explicit `return` inside a block body is a non-local return: it // unwinds to the method the block was written in, not just the block // frame. Operand A=1 flags it so the VM raises the unwinding signal; @@ -3165,6 +3185,31 @@ func (c *Compiler) storeLocal(name string) { // compileBegin compiles begin/rescue/else/ensure. When an ensure clause is // present it wraps the rescue handling in a second handler that runs ensure on // both the normal and the propagating paths. +// emitErrinfoRestore writes the innermost open begin/rescue's saved errinfo back +// into the interpreter's cell, which is what leaving a rescue clause means for +// $!. It is a no-op outside any rescue clause (a bare `retry` in a `for`/block +// body reaches the Retry case with no rescue open). +func (c *Compiler) emitErrinfoRestore() { + if len(c.errinfoSlots) == 0 { + return + } + top := c.errinfoSlots[len(c.errinfoSlots)-1] + b := c.cur() + if top.owner != b { + return + } + b.emit(bytecode.OpGetLocal, top.slot, 0) + b.emit(bytecode.OpSetGVar, b.addName(bytecode.ErrinfoCell), 0) + b.emit(bytecode.OpPop, 0, 0) +} + +// errinfoScope is one open begin/rescue's saved-errinfo slot and the ISeq builder +// the slot belongs to. +type errinfoScope struct { + owner *builder + slot int +} + func (c *Compiler) compileBegin(v *ast.Begin) { if v.EnsureBody == nil { c.compileBeginRescue(v) @@ -3191,6 +3236,28 @@ func (c *Compiler) compileBeginRescue(v *ast.Begin) { c.compileBody(v.Body) return } + // $! is per-RESCUE-CLAUSE state in MRI, not per frame: reading it walks the + // control frames out to the innermost ISEQ_TYPE_RESCUE frame and returns THAT + // frame's last local (errinfo_place, eval.c ruby_4_0:2009-2029), falling back to + // ec->errinfo only when no such frame is on the stack. So when a rescue clause + // completes, $! reverts to the enclosing view for free — the storage went away + // with the frame. rbgo does not compile a rescue clause into its own ISeq, so + // the scope has to be made explicit: save the errinfo cell into an anonymous + // local before the handler is armed, and write it back on every path that + // LEAVES a clause. The C mirror of the same rule is rb_vrescue2's + // `ec->errinfo = e_info;` after a handler returns (eval.c ruby_4_0:1109) — + // RESTORED to the entry value, not cleared to nil. + // + // The save must precede OpPushHandler because the interpreter overwrites the + // cell when a handler takes over; it must also precede it so `retry`, which + // jumps to the handler push, does not re-save the exception as the outer value. + errSlot := b.localSlot("") + b.emit(bytecode.OpGetGVar, b.addName(bytecode.ErrinfoCell), 0) + b.emit(bytecode.OpSetLocal, errSlot, 0) + b.emit(bytecode.OpPop, 0, 0) + c.errinfoSlots = append(c.errinfoSlots, errinfoScope{owner: b, slot: errSlot}) + defer func() { c.errinfoSlots = c.errinfoSlots[:len(c.errinfoSlots)-1] }() + h := b.emit(bytecode.OpPushHandler, 0, 0) c.compileBody(v.Body) b.emit(bytecode.OpPopHandler, 0, 0) @@ -3233,6 +3300,8 @@ func (c *Compiler) compileBeginRescue(v *ast.Begin) { } b.emit(bytecode.OpPop, 0, 0) // drop the exception (each store left it on the stack) c.compileBody(clause.Body) + // The clause completed: close its errinfo scope before the merge point. + c.emitErrinfoRestore() done = append(done, b.emit(bytecode.OpJump, 0, 0)) b.patch(skip, b.here()) } diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 3457916a..3a4d59c4 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -2099,6 +2099,18 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, case bytecode.OpGetConstTop: // Leading `::Name`: top-level only, ignoring lexical nesting. name := iseq.Names[in.A] + // `::Name` is a QUALIFIED reference — MRI compiles it to the same + // colon2/colon3 path that goes through rb_public_const_get_from, so + // Module#private_constant applies even when the holder is Object: + // `Object.send(:private_constant, :X); ::X` raises NameError "private + // constant Object::X referenced" on ruby 4.0.5, while the BAREWORD `X` + // still resolves. rbgo enforced this for `Recv::NAME` but not for the + // leading-`::` form, which was the one shape that could still read a + // private top-level constant. + if vm.cObject.privateConsts[name] { + push(vm.privateConstReferenced(vm.cObject, vm.cObject, name)) + break + } v, ok := vm.cObject.consts[name] if !ok { raise("NameError", "uninitialized constant %s", name) @@ -2138,10 +2150,29 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, vm.warnAlreadyInitialized(lexCref, iseq.Names[in.A]) vm.assignConst(lexCref, iseq.Names[in.A], stack[len(stack)-1]) case bytecode.OpGetGVar: - push(vm.gvar(iseq.Names[in.A])) + // bytecode.ErrinfoCell ("#$!") is the reserved name the begin/rescue + // lowering uses for the interpreter's errinfo cell — the value `$!` + // reports. It bypasses vm.gvar/vm.setGVar because `$!` is read-only to + // Ruby (eval.c ruby_4_0:2225 registers it with a 0 setter) while the + // lowering must both read and write it. No Ruby program can name it. + if name := iseq.Names[in.A]; name == bytecode.ErrinfoCell { + // curExc starts as the zero object.Value (a Go nil), which nothing on + // the operand stack may be; normalise it the way the $! getter does. + if vm.curExc == nil { + push(object.NilV) + } else { + push(vm.curExc) + } + } else { + push(vm.gvar(name)) + } case bytecode.OpSetGVar: // Assignment is an expression: set the global, keep its value. - vm.setGVar(iseq.Names[in.A], stack[len(stack)-1]) + if name := iseq.Names[in.A]; name == bytecode.ErrinfoCell { + vm.curExc = stack[len(stack)-1] + } else { + vm.setGVar(name, stack[len(stack)-1]) + } case bytecode.OpGetCVar: name := iseq.Names[in.A] vm.checkCVarScope(definee) @@ -2592,7 +2623,11 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, push(object.NilV) } case bytecode.OpDefinedConstTop: - if _, ok := vm.cObject.consts[iseq.Names[in.A]]; ok { + // defined?(::Name) answers for the same reference `::Name` makes, so a + // private top-level constant is NOT defined? through it (measured nil on + // ruby 4.0.5) although the bareword form reports "constant". + name := iseq.Names[in.A] + if _, ok := vm.cObject.consts[name]; ok && !vm.cObject.privateConsts[name] { push(definedTag(bytecode.DefinedConst)) } else { push(object.NilV) @@ -2915,13 +2950,26 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // run an intervening ensure body before continuing it, then // resume the unwind via OpReThrow (pendingSignal). A plain // rescue handler does not apply, so it is skipped past. + // if h, found := popToEnsure(&handlers); found { + // An ensure body still runs INSIDE this frame, so the rescue + // scopes it sits in are not gone yet: $! keeps whatever the + // clause-exit restore left (ruby/spec measures an `ensure` + // nested in a returning rescue clause seeing the OUTER + // exception, not nil). stack = stack[:h.sp] restoreFrameStacks() pendingSignal = r pc = h.pc return } + // No ensure left: the unwind LEAVES this frame, and every rescue + // clause it had open goes with it — in MRI the rescue frames are + // popped and $! reverts to the enclosing view. The compiler closes + // the scope for a `return` written in the clause itself; it cannot + // for `foo { return }`, where the return is in ANOTHER ISeq and a + // slot index would mean nothing there. That is this case. + vm.curExc = prevCurExc panic(r) } if len(handlers) == 0 { From eab41c83de875466a5058adb8427bc083450ed47 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 27 Sep 2026 17:05:25 +0200 Subject: [PATCH 3/5] internal/vm: tests for the frame scope, the $! clause lifecycle and ::Const privacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every expectation was measured against ruby 4.0.5 first: 40 snippets run through /opt/homebrew/bin/ruby and through the branch, 38 byte-identical and the two differences only in a backtrace label and a message prefix (both asserted as substrings). One `want` I had written from memory was wrong and the oracle caught it: a bare `raise` with $! cleared gives RuntimeError with an EMPTY message, not "unhandled exception". TestBindingSourceLocationFile pinned line 0, which was rbgo's own limitation and not a fact about Ruby — bind_location returns bind->first_lineno (proc.c ruby_4_0:805-815). It now pins a real line. Co-Authored-By: Claude Opus 5 --- internal/vm/binding_internal_test.go | 55 ++++++++- internal/vm/wave51_frame_scope_test.go | 149 +++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 internal/vm/wave51_frame_scope_test.go diff --git a/internal/vm/binding_internal_test.go b/internal/vm/binding_internal_test.go index 0ae1908c..bfaf7566 100644 --- a/internal/vm/binding_internal_test.go +++ b/internal/vm/binding_internal_test.go @@ -4,20 +4,26 @@ import ( "bytes" "testing" + "github.com/go-embedded-ruby/ruby/internal/bytecode" "github.com/go-embedded-ruby/ruby/internal/object" ) // TestBindingSourceLocationFile drives Binding#source_location for a file-backed // binding: the black-box harness compiles from a bare string (no File), so the -// non-nil branch — [file, line] with the VM's line-0 convention — is asserted -// here against a Binding carrying an explicit file. +// non-nil branch — [file, line] — is asserted here against a Binding carrying an +// explicit file and line. +// +// It used to pin line 0, which was rbgo's own limitation rather than a fact about +// Ruby: bind_location returns INT2FIX(bind->first_lineno) (proc.c +// ruby_4_0:805-815), and `def m; binding; end; p m.source_location` on ruby 4.0.5 +// reports the capture LINE. The frame's live pc now supplies it. func TestBindingSourceLocationFile(t *testing.T) { vm := New(&bytes.Buffer{}) m := lookupMethod(vm.consts["Binding"].(*RClass), "source_location") if m == nil || m.native == nil { t.Fatal("Binding#source_location not registered") } - got := m.native(vm, &Binding{file: "app.rb"}, nil, nil) + got := m.native(vm, &Binding{file: "app.rb", line: 12}, nil, nil) arr, ok := got.(*object.Array) if !ok || len(arr.Elems) != 2 { t.Fatalf("source_location = %v, want a 2-element array", got) @@ -25,8 +31,47 @@ func TestBindingSourceLocationFile(t *testing.T) { if s, ok := arr.Elems[0].(*object.String); !ok || s.Str() != "app.rb" { t.Errorf("file = %v, want \"app.rb\"", arr.Elems[0]) } - if arr.Elems[1] != object.IntValue(0) { - t.Errorf("line = %v, want 0", arr.Elems[1]) + if arr.Elems[1] != object.IntValue(12) { + t.Errorf("line = %v, want 12", arr.Elems[1]) + } +} + +// TestFrameBindingWalksOutward covers frameBinding's two structural cases in +// package: a frame whose scope exec has NOT published is skipped, and an index +// with no such frame below it falls back to the top-level binding. MRI reaches the +// same two answers through rb_vm_get_binding_creatable_next_cfp, which walks +// outward past a frame that cannot make a binding (vm_core.h ruby_4_0:1993). +func TestFrameBindingWalksOutward(t *testing.T) { + vm := New(&bytes.Buffer{}) + + // Nothing on the stack: the only scope that exists is the top level's. + b := vm.frameBinding(-1) + if b == nil || b.self != vm.main || b.file != toplevelBindingFile { + t.Fatalf("frameBinding(-1) = %+v, want the top-level binding", b) + } + if got := vm.callerBinding(); got == nil { + t.Fatal("callerBinding must never be nil") + } + + // Two frames, only the OUTER one carrying a scope: the walk must skip the inner + // one and answer with the outer env, not with the top-level fallback. + outerEnv := &Env{slots: []object.Value{object.IntValue(7)}} + vm.frameCode = []frameCode{ + {iseq: &bytecode.ISeq{File: "outer.rb", Locals: []string{"a"}}, env: outerEnv, self: vm.main, definee: vm.cObject}, + {iseq: &bytecode.ISeq{File: "inner.rb"}}, + } + b = vm.frameBinding(1) + if b.env != outerEnv { + t.Errorf("env = %p, want the outer frame's %p", b.env, outerEnv) + } + if b.file != "outer.rb" || len(b.names) != 1 || b.names[0] != "a" { + t.Errorf("binding = {file:%q names:%v}, want outer.rb with [a]", b.file, b.names) + } + + // An index past the end clamps rather than panicking: myFrame can legitimately + // outrun frameCode when a thread body has run through the Run boundary. + if got := vm.frameBinding(99); got.file != "outer.rb" { + t.Errorf("frameBinding(99) file = %q, want the clamped top frame's outer.rb", got.file) } } diff --git a/internal/vm/wave51_frame_scope_test.go b/internal/vm/wave51_frame_scope_test.go new file mode 100644 index 00000000..1015ee23 --- /dev/null +++ b/internal/vm/wave51_frame_scope_test.go @@ -0,0 +1,149 @@ +package vm_test + +import ( + "strings" + "testing" +) + +// TestNativeReachesCallerScope covers what a frame carrying its (env, self, +// definee) triple makes possible: a NATIVE method reaching the calling scope. +// Every `want` below is the measured answer of ruby 4.0.5 (/opt/homebrew/bin/ruby, +// "ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +PRISM [arm64-darwin25]"), not +// rbgo's — the shapes marked "was NoMethodError" all used to raise, because the +// only route to the caller's locals was a compiler rewrite of the bareword +// `eval(str)` into `eval(str, binding)`. +func TestNativeReachesCallerScope(t *testing.T) { + cases := []struct{ src, want string }{ + // The shape that already worked, kept as the control. + {`def m; x = 41; eval("x + 1"); end; p m`, "42\n"}, + {`def m; x = 41; eval("x + 1", binding); end; p m`, "42\n"}, + // #send: no compiler rewrite can see this one (was NoMethodError). + {`def m; x = 41; send(:eval, "x + 1"); end; p m`, "42\n"}, + // An alias and a Method object reach the same body (were NoMethodError). + {`alias ev eval; def m; x = 41; ev("x + 1"); end; p m`, "42\n"}, + {`def m; x = 41; method(:eval).call("x + 1"); end; p m`, "42\n"}, + // Arities other than 1: an explicit nil scope, and the 4-argument form + // (were NoMethodError, the rewrite only ever fired for exactly one argument). + {`def m; x = 7; eval("x", nil); end; p m`, "7\n"}, + {`def m; x = 7; eval("x", nil, "f.rb", 10); end; p m`, "7\n"}, + // eval takes the LOCALS from the caller and `self` from the RECEIVER: the two + // halves come from different places, which ruby 4.0.5 measures as [5, 1]. + {`x = 1; p 5.send(:eval, "[self, x]")`, "[5, 1]\n"}, + // Kernel#binding is a METHOD, so #send reaches it (was NoMethodError). + {`p send(:binding).class`, "Binding\n"}, + {`def r; zz = 1; send(:binding); end; p r.local_variables`, "[:zz]\n"}, + // ... and it is PRIVATE, as rb_define_global_function makes it. + {`p Object.new.respond_to?(:binding)`, "false\n"}, + {`p Object.private_method_defined?(:binding)`, "true\n"}, + // A Binding carries the method context of the frame it was captured in, so + // eval'd code reports that method however far away the eval happens. + {`def q; binding; end; b = q; p b.eval("__method__")`, ":q\n"}, + // TOPLEVEL_BINDING reports MRI's synthetic location rather than nothing. + {`p TOPLEVEL_BINDING.source_location`, "[\"
\", 0]\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q\n got=%q\nwant=%q", c.src, got, c.want) + } + } + + errs := []struct{ src, want string }{ + // Arity 0, as rb_f_binding is registered with. + {`send(:binding, 1)`, "wrong number of arguments (given 1, expected 0)"}, + // Private, so an explicit receiver is refused (ruby 4.0.5: "private method + // 'binding' called for an instance of Object"). + {`Object.new.binding`, "private method 'binding' called"}, + } + for _, c := range errs { + if err := runErr(t, c.src); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("src=%q err=%v, want substring %q", c.src, err, c.want) + } + } +} + +// TestErrinfoIsPerRescueClause pins the $! lifecycle. MRI keeps errinfo in the +// innermost rescue FRAME's last local (errinfo_place, eval.c ruby_4_0:2009-2029), +// so leaving a clause reverts $! to the enclosing view for free. Every `want` is +// the measured answer of ruby 4.0.5; the four marked "(was the inner one)" are +// cases where rbgo used to report a DIFFERENT exception, not merely a stale one. +func TestErrinfoIsPerRescueClause(t *testing.T) { + cases := []struct{ src, want string }{ + // Inside the clause: the exception. The control for everything below. + {`begin; raise "a"; rescue; p $!.message; end`, "\"a\"\n"}, + // After the clause completes: back to the enclosing view, which is nil here. + {`begin; raise "a"; rescue; end; p $!`, "nil\n"}, + // A second begin in the same frame no longer starts with the first's exception. + {`begin; raise "a"; rescue; end; begin; p $!; rescue; end`, "nil\n"}, + // Nested: the OUTER exception comes back when the inner clause ends + // (was the inner one). + {`begin; raise "outer"; rescue; begin; raise "inner"; rescue; end; p $!.message; end`, "\"outer\"\n"}, + // An `else` clause and an enclosing `ensure` are both outside the clause + // (was the inner one). + {`begin; raise "a"; rescue; else; end; p $!`, "nil\n"}, + {`begin; begin; raise "a"; rescue; end; ensure; p $!; end`, "nil\n"}, + // `retry` leaves the clause too: after one that succeeds, $! is the outer + // value (was the inner one). + {`n = 0; begin; n += 1; raise "r" if n < 2; rescue; retry; end; p [n, $!]`, "[2, nil]\n"}, + // A bare `raise` after a successful rescue raises a FRESH RuntimeError + // rather than re-raising the handled exception (was the inner one). + {`begin; raise "a"; rescue; end; begin; raise; rescue => e; p [e.class, e.message]; end`, + "[RuntimeError, \"\"]\n"}, + // A method called FROM a rescue clause still sees the clause's exception: + // its own frame does not close the caller's scope. + {`def peek; $!.message; end; begin; raise "a"; rescue; p peek; end`, "\"a\"\n"}, + // A method that rescued internally leaves nothing behind for its caller. + {`def h; begin; raise "h"; rescue; end; end; h; p $!`, "nil\n"}, + // An ensure nested INSIDE a rescue clause is still inside it. + {`begin; raise "a"; rescue; begin; 1; ensure; p $!.message; end; end`, "\"a\"\n"}, + // An ensure reached by an in-flight exception sees that exception. + {`begin; begin; raise "x"; ensure; p $!.message; end; rescue; end`, "\"x\"\n"}, + // A `return` out of a rescue clause closes its scope: the enclosing clause's + // exception comes back, and an intervening ensure still inside the frame + // sees it (three ruby/spec examples measure exactly this shape). + {`def f; begin; raise "outer"; rescue; begin; raise "inner"; rescue; return; ensure; p $!.message; end; end; end; f; p $!`, + "\"outer\"\nnil\n"}, + // The return the compiler cannot see: it lives in ANOTHER ISeq, so the + // interpreter closes the scope as the unwind leaves the frame. + {`def g; [1].each { begin; raise "e"; rescue; return; end }; end; g; p $!`, "nil\n"}, + {`def k; 1.times { return }; end; def bar; begin; raise "b"; rescue; k; end; end; bar; p $!`, "nil\n"}, + // $! is read-only to Ruby, and the reserved cell the lowering uses does not + // change that. + {`begin; eval("$! = RuntimeError.new"); rescue NameError => e; p e.message; end`, + "\"$! is a read-only variable\"\n"}, + // $@ follows $!. + {`begin; raise "a"; rescue; end; p $@`, "nil\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q\n got=%q\nwant=%q", c.src, got, c.want) + } + } +} + +// TestPrivateConstantUnderLeadingColon2 covers `::Name`, the one qualified +// constant form that could still read a constant Module#private_constant had +// marked. Measured on ruby 4.0.5: the reference raises NameError while the +// BAREWORD still resolves, and defined?(::Name) is nil while defined?(Name) is +// "constant". +func TestPrivateConstantUnderLeadingColon2(t *testing.T) { + const setup = "PRIV_C = 1; Object.send :private_constant, :PRIV_C; " + cases := []struct{ src, want string }{ + {setup + `p defined?(::PRIV_C)`, "nil\n"}, + {setup + `p defined?(PRIV_C)`, "\"constant\"\n"}, + {setup + `p PRIV_C`, "1\n"}, + {setup + `p defined?(::NOT_THERE_AT_ALL)`, "nil\n"}, + // A public constant is unaffected by either form. + {`PUB_C = 2; p [::PUB_C, defined?(::PUB_C)]`, "[2, \"constant\"]\n"}, + // An overriding const_missing intercepts the private reference, exactly as + // it does for the Recv::NAME form. + {setup + `def Object.const_missing(n); [:cm, n]; end; p ::PRIV_C`, "[:cm, :PRIV_C]\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q\n got=%q\nwant=%q", c.src, got, c.want) + } + } + if err := runErr(t, setup+`::PRIV_C`); err == nil || !strings.Contains(err.Error(), "private constant") { + t.Errorf("::PRIV_C err=%v, want a private-constant NameError", err) + } +} From a1715f23d6fa588d50b10389a891603c91a6287c Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 27 Sep 2026 17:13:51 +0200 Subject: [PATCH 4/5] internal/vm: Kernel#eval takes the DEFINEE from the calling frame too, and evalDefinee goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first commit left the definee coming from evalDefinee(self) so that only the locals moved. That turned out to be a downward move of its own, caught by the whole-corpus sweep: language/def_spec.rb 61 -> 60, "A method definition in an eval creates a class method". class K class << self def mk; eval "def m; self; end"; m; end end end K.mk # ruby 4.0.5: K The definee there is the singleton class of K, which is the calling frame's cref. Derived from self (K, a Class) the def landed as an INSTANCE method and K.m raised NoMethodError. The bareword rewrite hid it, because the binding it spliced in carried the frame's real definee — so this was never a fact about eval, it was the rewrite standing in for a frame stack. evalDefinee is DELETED, not left uncalled, and a note in its place records the invariant it stood on. Its own comment already conceded it was a stand-in for a cref rbgo could not reach ("a cref pushed by instance_eval over a module is not recoverable from self alone"); a frame now carries its definee, so there is nothing left for it to approximate, and an unreachable approximation of a fact the VM knows is exactly what a later wave would trust. language/def_spec.rb is back to 61. core/kernel/eval_spec.rb stays at 47. Co-Authored-By: Claude Opus 5 --- internal/vm/eval.go | 43 ++++++++++++-------------- internal/vm/wave51_frame_scope_test.go | 7 +++++ 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/internal/vm/eval.go b/internal/vm/eval.go index 130d755a..a68de47f 100644 --- a/internal/vm/eval.go +++ b/internal/vm/eval.go @@ -231,13 +231,16 @@ func (vm *VM) registerEval() { // Reading the caller's frame here covers all of them at once, and the rewrite // is gone. // - // Only the LOCAL SCOPE is taken from the caller here: the definee keeps - // coming from evalDefinee(self) below, so this change adds the locals and - // alters nothing else. (MRI takes the definee from the caller's CREF too, - // which the frame now carries — a separate move, measured separately.) + // The DEFINEE is the calling frame's too. Deriving it from self was only ever + // a stand-in for a cref rbgo could not reach, and it is measurably wrong: + // `class K; class << self; def mk; eval "def m; self; end"; m; end; end; K.mk` + // is K on ruby 4.0.5, and with the definee derived from self (K, a Class) the + // def landed as an INSTANCE method and K.m raised NoMethodError. The bareword + // rewrite used to hide that, because the binding it spliced in carried the + // frame's real definee; reading the frame directly keeps it and extends it to + // every other shape. cb := vm.callerBinding() cb.self = self - cb.definee = vm.evalDefinee(self) return vm.bindingEval(cb, src, loc) }) } @@ -262,23 +265,15 @@ func (vm *VM) wrongTypeName(v object.Value) string { return vm.classOf(v).name } -// evalDefinee is where a `def` inside eval(str) — no binding, so no captured -// definee — lands. MRI takes the CALLER's cref (eval_string_with_cref copies the -// calling frame's, vm_eval.c ruby_4_0:2010-2013), which rbgo has no reified -// stack of; classOf(self) stood in for it. +// evalDefinee is GONE, deliberately, and this note records the invariant it stood +// on so nobody reintroduces it. It answered "where does a `def` inside eval(str) +// land" by deriving the definee from `self`, because MRI takes it from the +// CALLER'S CREF (eval_string_with_cref copies the calling frame's, vm_eval.c +// ruby_4_0:2010-2013) and rbgo had no reified frame stack to read one from. Its +// own comment conceded it remained a stand-in: "a cref pushed by instance_eval +// over a module is not recoverable from self alone". // -// That stand-in is right for an ordinary object, whose cref while its methods run -// is its class. It is never right when self is a Module or Class: the cref there -// is the module ITSELF — that is what a class body, and a `def self.x` inside -// one, run under — and classOf answered Class or Module, so -// `Class.new { eval("def m; end") }` defined m on Class rather than on the new -// class, and the new class reported no such method. -// -// It remains a stand-in: a cref pushed by instance_eval over a module (self is -// the module, the cref its singleton class) is not recoverable from self alone. -func (vm *VM) evalDefinee(self object.Value) *RClass { - if cls, ok := self.(*RClass); ok { - return cls - } - return vm.classOf(self) -} +// A frame now carries its definee (frameCode), so Kernel#eval reads the real one +// and the stand-in has nothing left to approximate. Deleting it rather than +// leaving it uncalled is the point: an unreachable approximation of a fact the VM +// now knows is the shape a later wave would trust. diff --git a/internal/vm/wave51_frame_scope_test.go b/internal/vm/wave51_frame_scope_test.go index 1015ee23..011c0768 100644 --- a/internal/vm/wave51_frame_scope_test.go +++ b/internal/vm/wave51_frame_scope_test.go @@ -38,6 +38,13 @@ func TestNativeReachesCallerScope(t *testing.T) { // A Binding carries the method context of the frame it was captured in, so // eval'd code reports that method however far away the eval happens. {`def q; binding; end; b = q; p b.eval("__method__")`, ":q\n"}, + // The DEFINEE comes from the calling frame as well, so a `def` inside + // eval(str) lands where MRI's caller-cref puts it. Derived from self instead + // (K is a Class) it landed as an instance method and K.m raised NoMethodError; + // ruby 4.0.5 answers K for both lines below. + {`class K; class << self; def mk; eval "def m; self; end"; m; end; end; end; p K.mk`, "K\n"}, + {`class K2; class << self; def mk; eval("def m; self; end", binding); m; end; end; end; p K2.mk`, "K2\n"}, + {`p Class.new { eval("def m; 7; end") }.new.m`, "7\n"}, // TOPLEVEL_BINDING reports MRI's synthetic location rather than nothing. {`p TOPLEVEL_BINDING.source_location`, "[\"
\", 0]\n"}, } From ca06f34b2d2b9e157d9986d1d365ebd7a8a02146 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 27 Sep 2026 17:34:11 +0200 Subject: [PATCH 5/5] internal/compiler: cover emitErrinfoRestore's owner-mismatch refusal, and unstick a doc comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go 1.27.1 coverage showed exactly ONE new below-100 entry against origin/main: emitErrinfoRestore at 88.9%, with the uncovered block being the `return` taken when the innermost open rescue scope belongs to ANOTHER ISeq. It is reachable — `begin; raise; rescue; [1].each { return 42 }; end`, where the `return` compiles into the block's builder — so it gets a test rather than a deletion. ruby 4.0.5 answers 42 then nil for that, and [7, nil] for the `1.times` form; both measured. With the one test the function reads 100.0%, so the below-100 LIST is the same 16 entries as origin/main on macOS (that list, not the rounded total, is the criterion). The doc comment for compileBegin had been left attached to emitErrinfoRestore, which made it document the wrong function. Both now say what they are, and emitErrinfoRestore's says why each of its two refusals is load-bearing rather than defensive. Co-Authored-By: Claude Opus 5 --- internal/compiler/compiler.go | 18 ++++++++++++------ internal/vm/wave51_frame_scope_test.go | 6 ++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/internal/compiler/compiler.go b/internal/compiler/compiler.go index 1f2ad1fd..ad4d926d 100644 --- a/internal/compiler/compiler.go +++ b/internal/compiler/compiler.go @@ -3182,13 +3182,16 @@ func (c *Compiler) storeLocal(name string) { c.cur().emit(bytecode.OpSetLocal, slot, depth) } -// compileBegin compiles begin/rescue/else/ensure. When an ensure clause is -// present it wraps the rescue handling in a second handler that runs ensure on -// both the normal and the propagating paths. // emitErrinfoRestore writes the innermost open begin/rescue's saved errinfo back -// into the interpreter's cell, which is what leaving a rescue clause means for -// $!. It is a no-op outside any rescue clause (a bare `retry` in a `for`/block -// body reaches the Retry case with no rescue open). +// into the interpreter's cell, which is what leaving a rescue clause means for $!. +// +// It declines in two cases, and both are load-bearing rather than defensive. +// With no rescue clause open there is nothing to close — every `return` in the +// program reaches here, and almost none of them is inside one. And when the +// innermost open scope belongs to ANOTHER ISeq — `begin; raise; rescue; [1].each +// { return }; end`, where the return is compiled into the block's builder — the +// slot index would address a different frame's locals entirely; the interpreter +// closes that scope instead, as the unwind leaves the frame. func (c *Compiler) emitErrinfoRestore() { if len(c.errinfoSlots) == 0 { return @@ -3210,6 +3213,9 @@ type errinfoScope struct { slot int } +// compileBegin compiles begin/rescue/else/ensure. When an ensure clause is +// present it wraps the rescue handling in a second handler that runs ensure on +// both the normal and the propagating paths. func (c *Compiler) compileBegin(v *ast.Begin) { if v.EnsureBody == nil { c.compileBeginRescue(v) diff --git a/internal/vm/wave51_frame_scope_test.go b/internal/vm/wave51_frame_scope_test.go index 011c0768..3dd252c8 100644 --- a/internal/vm/wave51_frame_scope_test.go +++ b/internal/vm/wave51_frame_scope_test.go @@ -113,6 +113,12 @@ func TestErrinfoIsPerRescueClause(t *testing.T) { // interpreter closes the scope as the unwind leaves the frame. {`def g; [1].each { begin; raise "e"; rescue; return; end }; end; g; p $!`, "nil\n"}, {`def k; 1.times { return }; end; def bar; begin; raise "b"; rescue; k; end; end; bar; p $!`, "nil\n"}, + // The same shape with the block written INSIDE the clause: the `return` is + // compiled into the block's builder, so the clause's slot index means nothing + // there and the compiler declines to emit the restore — the interpreter closes + // the scope instead. ruby 4.0.5 answers 42 then nil, and [7, nil]. + {`def m; begin; raise "x"; rescue; [1].each { return 42 }; end; end; p m; p $!`, "42\nnil\n"}, + {`def m; begin; raise "x"; rescue; 1.times { return 7 }; end; end; p [m, $!]`, "[7, nil]\n"}, // $! is read-only to Ruby, and the reserved cell the lowering uses does not // change that. {`begin; eval("$! = RuntimeError.new"); rescue NameError => e; p e.message; end`,