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 771b8f8a..ad4d926d 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; @@ -1297,16 +1317,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 @@ -3165,6 +3182,37 @@ func (c *Compiler) storeLocal(name string) { c.cur().emit(bytecode.OpSetLocal, slot, depth) } +// 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 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 + } + 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 +} + // 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. @@ -3194,6 +3242,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) @@ -3236,6 +3306,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/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/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/eval.go b/internal/vm/eval.go index e3f15f0d..a68de47f 100644 --- a/internal/vm/eval.go +++ b/internal/vm/eval.go @@ -219,14 +219,29 @@ 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. + // + // 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 + return vm.bindingEval(cb, src, loc) }) } @@ -250,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/vm.go b/internal/vm/vm.go index 409b7f7c..3a4d59c4 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) @@ -2021,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) @@ -2060,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) @@ -2514,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) @@ -2586,7 +2699,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: @@ -2835,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 { diff --git a/internal/vm/wave51_frame_scope_test.go b/internal/vm/wave51_frame_scope_test.go new file mode 100644 index 00000000..3dd252c8 --- /dev/null +++ b/internal/vm/wave51_frame_scope_test.go @@ -0,0 +1,162 @@ +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"}, + // 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"}, + } + 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"}, + // 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`, + "\"$! 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) + } +}