From 659eaebaa7f38e3da11d7f2ad4e42ba80f4c52bd Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 25 Sep 2026 10:14:04 +0200 Subject: [PATCH 1/6] =?UTF-8?q?vm,bytecode:=20TracePoint=20=E2=80=94=20the?= =?UTF-8?q?=20object,=20the=20event=20set,=20and=20hooks=20in=20exec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no TracePoint code in the tree; core/tracepoint was 0 of 75. Read before designing (ruby/ruby v3_4_0): include/ruby/internal/event.h for the RUBY_EVENT_* bit set, vm_trace.c for tracepoint_new_s (:1490), tracepoint_enable_m (:1375), tracepoint_disable_m (:1424), tracepoint_inspect (:1521), rb_tracearg_* (:862-1080), rb_tracepoint_enable_for_target (:1234), tracepoint_allow_reentry (:1607), get_path_and_lineno (:694), hook_list_connect and rb_exec_event_hooks (:412); iseq.c for rb_iseq_trace_set (:3981) and rb_iseq_add_local_tracepoint_recursively (:3912); vm_insnhelper.c for vm_trace_hook (:7054). Events raised: line, class, end, call, return, b_call, b_return, rescue. Left out with reasons in supportedTraceEvents: c_call/c_return, raise, script_compiled, thread_begin/thread_end/fiber_switch — each needs a hook in a file outside this change. The hot path. MRI never tests a flag per instruction: it emits distinct trace instructions and rb_iseq_trace_set rewrites their encodings, so an untraced program executes none. rbgo has no instruction to rewrite, so the gate is one word — vm.traceEvents, zero unless a TracePoint is enabled — tested once per instruction beside the pc publication that was already there. Everything past it, including the line-map search, is behind the gate. Order is MRI's, and it is not registration order: global hooks first, then the ISeq's local (target:) ones, each list walked most-recently- enabled first, because hook_list_connect prepends. enable_spec pins all three combinations. core/tracepoint: 0 -> 69 of 75. Co-Authored-By: Claude Opus 5 --- internal/bytecode/bytecode.go | 46 ++ internal/vm/tracepoint.go | 908 ++++++++++++++++++++++++++++++++++ internal/vm/vm.go | 122 +++++ 3 files changed, 1076 insertions(+) create mode 100644 internal/vm/tracepoint.go diff --git a/internal/bytecode/bytecode.go b/internal/bytecode/bytecode.go index 9e31a3a4..8b0990c5 100644 --- a/internal/bytecode/bytecode.go +++ b/internal/bytecode/bytecode.go @@ -396,3 +396,49 @@ func (s *ISeq) HandlerState() uint8 { return s.handlerState } // the exported field set so the AOT freeze emitter (which writes only the // source-level fields) is unaffected. func (s *ISeq) SetHandlerState(v uint8) { s.handlerState = v } + +// IsLineStart reports whether pc is the FIRST instruction of a line run — the +// place a `line` trace event belongs. +// +// MRI does not answer this at run time at all: its compiler emits a distinct +// `trace` instruction wherever a new line begins (iseq_compile_each keeps +// last_line and only adds one when the line changes), and rb_iseq_trace_set +// (iseq.c v3_4_0:3981) flips those encodings on when a hook wants +// ISEQ_TRACE_EVENTS, so a program with no TracePoint executes no trace +// instruction and pays nothing. rbgo has no trace instruction to flip, so the +// same question is asked of the line map instead — and the map already holds +// exactly the same information, because Lines carries one entry per line CHANGE +// (see the field comment), which is the set of positions MRI would have marked. +// +// The search is LineAt's, narrowed to an exact hit: the entry found must start +// at pc, not merely cover it. Callers gate this on tracing being enabled, so +// the binary search never runs for an untraced program. +func (s *ISeq) IsLineStart(pc int) bool { + if len(s.Lines) == 0 || pc < s.Lines[0].PC { + return false + } + lo, hi := 0, len(s.Lines) + for hi-lo > 1 { + mid := int(uint(lo+hi) >> 1) + if s.Lines[mid].PC <= pc { + lo = mid + } else { + hi = mid + } + } + return s.Lines[lo].PC == pc +} + +// HasLine reports whether any instruction in this ISeq is mapped to the given +// 1-based source line. It is what decides MRI's "can not enable any hooks" +// ArgumentError for a `target_line:` that names no line of the target's code +// (rb_tracepoint_enable_for_target, vm_trace.c v3_4_0:1234, whose count n stays +// 0 when rb_iseq_add_local_tracepoint_recursively matches nothing). +func (s *ISeq) HasLine(line int) bool { + for _, e := range s.Lines { + if e.Line == line { + return true + } + } + return false +} diff --git a/internal/vm/tracepoint.go b/internal/vm/tracepoint.go new file mode 100644 index 00000000..e4e1c8d7 --- /dev/null +++ b/internal/vm/tracepoint.go @@ -0,0 +1,908 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +// TracePoint: the object, its event set, and the hook list the VM fires into. +// +// The shape here is MRI's, read from vm_trace.c and iseq.c at v3_4_0 rather +// than reconstructed from the documentation: +// +// - the event set is the bit field of include/ruby/internal/event.h +// (RUBY_EVENT_LINE 0x0001 … RUBY_EVENT_RESCUE 0x4000), and +// symbol2event_flag (vm_trace.c:804) is the name→bit table, including its +// "joke" aggregates :a_call and :a_return; +// - a TracePoint is created disabled and carries a Proc; tracepoint_new_s +// (vm_trace.c:1490) defaults the event set to RUBY_EVENT_TRACEPOINT_ALL +// when no event is named and raises ArgumentError "must be called with a +// block" when there is no block — and it validates the events BEFORE it +// looks for the block, which is why `TracePoint.new(:test)` reports the +// unknown event rather than the missing block; +// - enable/disable are tracepoint_enable_m / tracepoint_disable_m +// (vm_trace.c:1375 / :1424): each answers the PREVIOUS tracing state, and +// the block form scopes the change and restores that previous state on the +// way out (MRI's rb_ensure with rb_tracepoint_enable/disable as the +// ensure); +// - the accessors are rb_tracearg_* (vm_trace.c:862-1080), all of which read +// ec->trace_arg and raise RuntimeError "access from outside" when it is +// NULL (get_trace_arg, vm_trace.c:846). That same pointer is the REENTRY +// GUARD: rb_exec_event_hooks (vm_trace.c:434) fires nothing while it is +// set, which is what keeps a handler's own execution from tracing itself; +// - TracePoint.allow_reentry (vm_trace.c:1607) clears it for the duration of +// a block, and restores it in an ensure; +// - target:/target_line: are rb_tracepoint_enable_for_target +// (vm_trace.c:1234): the target is resolved to an ISeq, hooks are attached +// to it and its children recursively, and attaching NOTHING is the +// ArgumentError "can not enable any hooks". +// +// What rbgo fires and what it does not is recorded at supportedTraceEvents. +package vm + +import ( + "fmt" + "strings" + + "github.com/go-embedded-ruby/ruby/internal/bytecode" + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// traceEvents is MRI's rb_event_flag_t, restricted to the TracePoint half of +// the bit space (include/ruby/internal/event.h, v3_4_0). The values are MRI's +// literal bits, not a re-numbering, so a set read from one side reads the same +// on the other. +type traceEvents uint32 + +const ( + evLine traceEvents = 0x0001 + evClass traceEvents = 0x0002 + evEnd traceEvents = 0x0004 + evCall traceEvents = 0x0008 + evReturn traceEvents = 0x0010 + evCCall traceEvents = 0x0020 + evCReturn traceEvents = 0x0040 + evRaise traceEvents = 0x0080 + evBCall traceEvents = 0x0100 + evBReturn traceEvents = 0x0200 + evThreadBegin traceEvents = 0x0400 + evThreadEnd traceEvents = 0x0800 + evFiberSwitch traceEvents = 0x1000 + evScriptCompiled traceEvents = 0x2000 + evRescue traceEvents = 0x4000 + + // evTracePointAll is RUBY_EVENT_TRACEPOINT_ALL: the set TracePoint.new gets + // when it is given no event name at all (tracepoint_new_s, vm_trace.c:1490). + evTracePointAll traceEvents = 0xffff +) + +// supportedTraceEvents is the subset rbgo actually FIRES. A TracePoint may name +// any MRI event — an unknown name is still an ArgumentError, and an event +// outside this set is accepted and simply never delivered, exactly as a +// TracePoint for an event the program never reaches behaves. +// +// Left out, and why (each needs a hook in a file this change does not own, or a +// VM facility that does not exist yet): +// +// - :c_call / :c_return — a native body is invoked from callNative +// (object_model.go), and only from there; the OpSend sites in this file see +// a resolved *Method on the fast path but not on the send / method_missing +// / operator fallbacks, so hooking them here would fire for some native +// calls and not others, which is worse than not firing. +// - :raise — every Ruby-level raise funnels through excError/captureBacktrace +// (builtins.go). The only raise-shaped point reachable from here is the +// rescue dispatch, which is where :rescue fires; announcing :raise from +// there would report the rescuing frame's line as the raise site. +// - :script_compiled — eval compiles in eval.go / binding_eval_open.go, and +// rbgo forms no "(eval at FILE:LINE)" path for the compiled unit. +// - :thread_begin / :thread_end / :fiber_switch — thread.go / fiber.go. +const supportedTraceEvents = evLine | evClass | evEnd | evCall | evReturn | + evBCall | evBReturn | evRescue + +// traceEventNames is symbol2event_flag's table (vm_trace.c:804) read forwards. +// The order is MRI's, and the two aggregates at the end are MRI's too — the +// source calls them a joke, but `TracePoint.new(:a_call)` really does register +// call|b_call|c_call. +var traceEventNames = []struct { + name string + bits traceEvents +}{ + {"line", evLine}, + {"class", evClass}, + {"end", evEnd}, + {"call", evCall}, + {"return", evReturn}, + {"c_call", evCCall}, + {"c_return", evCReturn}, + {"raise", evRaise}, + {"b_call", evBCall}, + {"b_return", evBReturn}, + {"thread_begin", evThreadBegin}, + {"thread_end", evThreadEnd}, + {"fiber_switch", evFiberSwitch}, + {"script_compiled", evScriptCompiled}, + {"rescue", evRescue}, + {"a_call", evCall | evBCall | evCCall}, + {"a_return", evReturn | evBReturn | evCReturn}, +} + +// eventName is get_event_id (vm_trace.c:667) read backwards: the Symbol name of +// a SINGLE event bit. An aggregate has no name of its own (MRI returns 0), and +// no trace_arg ever carries one, because an event is delivered one bit at a time. +func eventName(e traceEvents) string { + for _, ev := range traceEventNames { + if ev.bits == e { + return ev.name + } + } + return "unknown" +} + +// tracePoint is MRI's rb_tp_t (vm_trace.c): the event mask, the handler Proc, +// whether it is currently registered, and the three filters a hook may carry +// (thread, local ISeq set, line). +// +// It doubles as the Go box hung off the Ruby object's @__tp ivar, so it +// satisfies object.Value; it is never itself a receiver, so vm.classOf needs no +// case for it. +type tracePoint struct { + events traceEvents + blk *Proc + self object.Value // the Ruby TracePoint the handler is called with + tracing bool + + // targetTh is MRI's tp->target_th: when non-nil the hook fires only on that + // thread. enable's `target_thread:` defaults to the CURRENT thread for the + // block form with no target (tracepoint_enable_m, vm_trace.c:1379) and to no + // filter otherwise. + targetTh *RThread + + // localSeqs is MRI's local_target_set: non-nil exactly when the TracePoint + // was enabled with `target:`, and holding the target ISeq plus every ISeq + // nested inside it (rb_iseq_add_local_tracepoint_recursively, iseq.c:3912). + // A local TracePoint fires only for an event whose ISeq is in the set. + localSeqs map[*bytecode.ISeq]bool + + // targetLine is MRI's hook->filter.target_line: 0 for no filter, otherwise + // only events on that source line are delivered (exec_hooks_body, + // vm_trace.c:341). + targetLine int +} + +func (t *tracePoint) ToS() string { return "#" } +func (t *tracePoint) Inspect() string { return "#" } +func (t *tracePoint) Truthy() bool { return true } + +// tpStateIvar is the hidden ivar the Go state hangs off. The name is the +// package's established shape for a Go-backed core object (see @__lexer, +// @__engine, @__tmpl elsewhere in this package). +const tpStateIvar = "@__tp" + +// traceArg is MRI's rb_trace_arg_t: everything an event carries, built once at +// the fire site and read by the accessors while the handler runs. ec->trace_arg +// points at it; here vm.traceArg does. +// +// The frame pieces (env/definee/locals) are kept rather than a finished Binding +// because building one pins the frame's Env out of the reuse pool +// (markEnvCaptured), and most events are never asked for a binding. +type traceArg struct { + event traceEvents + self object.Value + path string + line int + methodID string // "" renders as nil + calleeID string + klass object.Value // defined_class; NilV when the event has none + + iseq *bytecode.ISeq // the code the event came from; also the local-hook key + isProc bool // a non-lambda block frame: #parameters reports :opt + + hasData bool + data object.Value // return_value / raised_exception + + env *Env + definee *RClass + locals []string + file string +} + +// registerTracePoint installs the TracePoint class. It is called from New after +// the prelude, beside the other late registrations, so Object already exists. +func (vm *VM) registerTracePoint() { + cls := newClass("TracePoint", vm.cObject) + vm.consts["TracePoint"] = cls + vm.cTracePoint = cls + + sdef := func(name string, fn NativeFn) { + cls.smethods[name] = &Method{name: name, owner: cls, native: fn} + } + + // TracePoint.new(*events) { |tp| … } — tracepoint_new_s (vm_trace.c:1490). + sdef("new", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + return vm.newTracePoint(tpClassOf(vm, self), args, blk) + }) + // TracePoint.trace(*events) { |tp| … } — tracepoint_trace_s (vm_trace.c:1513): + // new, then enable, then hand back the (already tracing) object. + sdef("trace", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + tpv := vm.newTracePoint(tpClassOf(vm, self), args, blk) + vm.tracepointEnable(tpOf(tpv)) + return tpv + }) + // TracePoint.allow_reentry { … } — tracepoint_allow_reentry (vm_trace.c:1607). + sdef("allow_reentry", func(vm *VM, _ object.Value, _ []object.Value, blk *Proc) object.Value { + return vm.traceAllowReentry(blk) + }) + + cls.define("enabled?", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(tpOf(self).tracing) + }) + cls.define("enable", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + return vm.tracePointEnableM(tpOf(self), args, blk) + }) + cls.define("disable", func(vm *VM, self object.Value, _ []object.Value, blk *Proc) object.Value { + return vm.tracePointDisableM(tpOf(self), blk) + }) + cls.define("inspect", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(vm.tracePointInspect(tpOf(self))) + }) + + // The trace-argument accessors. Each is rb_tracearg_* in vm_trace.c, and each + // goes through currentTraceArg, which is get_trace_arg (vm_trace.c:846). + cls.define("event", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.SymVal(eventName(vm.currentTraceArg().event)) + }) + cls.define("lineno", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(vm.currentTraceArg().line)) + }) + cls.define("path", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(vm.currentTraceArg().path) + }) + cls.define("self", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.currentTraceArg().self + }) + cls.define("method_id", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return symOrNil(vm.currentTraceArg().methodID) + }) + cls.define("callee_id", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return symOrNil(vm.currentTraceArg().calleeID) + }) + cls.define("defined_class", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + a := vm.currentTraceArg() + if a.klass == nil { + return object.NilV + } + return a.klass + }) + cls.define("return_value", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + a := vm.currentTraceArg() + // rb_tracearg_return_value (vm_trace.c:1006): only the three return events + // carry one. + if a.event&(evReturn|evCReturn|evBReturn) == 0 { + raise("RuntimeError", "not supported by this event") + } + return a.data + }) + cls.define("raised_exception", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + a := vm.currentTraceArg() + // rb_tracearg_raised_exception (vm_trace.c:1021). + if a.event&(evRaise|evRescue) == 0 { + raise("RuntimeError", "not supported by this event") + } + return a.data + }) + cls.define("binding", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.traceArgBinding(vm.currentTraceArg()) + }) + cls.define("parameters", func(vm *VM, _ object.Value, _ []object.Value, _ *Proc) object.Value { + return traceArgParameters(vm.currentTraceArg()) + }) +} + +// tpClassOf is the class a TracePoint.new lands on: the receiver when it is a +// class (so a subclass makes instances of itself, as tracepoint_new_s passes +// `self` straight to tracepoint_new), else TracePoint. +func tpClassOf(vm *VM, self object.Value) *RClass { + if c, ok := self.(*RClass); ok { + return c + } + return vm.cTracePoint +} + +// symOrNil renders an optional ID: a Symbol, or nil for the absent one — what +// rb_tracearg_method_id / _callee_id do with a zero ID (vm_trace.c:960/:967). +func symOrNil(name string) object.Value { + if name == "" { + return object.NilV + } + return object.SymVal(name) +} + +// tpOf unwraps the Go state from a Ruby TracePoint. +func tpOf(v object.Value) *tracePoint { + if o, ok := v.(*RObject); ok { + if tp, ok := getIvar(o, tpStateIvar).(*tracePoint); ok { + return tp + } + } + raise("TypeError", "wrong argument type %s (expected TracePoint)", classNameOf(v)) + return nil +} + +// newTracePoint is tracepoint_new_s (vm_trace.c:1490). +func (vm *VM) newTracePoint(cls *RClass, args []object.Value, blk *Proc) object.Value { + var events traceEvents + if len(args) == 0 { + events = evTracePointAll + } else { + for _, a := range args { + events |= vm.symbolToEventFlag(a) + } + } + // The block check comes AFTER the event loop, as in MRI: an unknown event is + // reported even when no block was given. + if blk == nil { + raise("ArgumentError", "must be called with a block") + } + tp := &tracePoint{events: events, blk: blk} + obj := &RObject{class: cls, ivars: map[string]object.Value{tpStateIvar: tp}} + tp.self = obj + return obj +} + +// symbolToEventFlag is symbol2event_flag (vm_trace.c:804): coerce to a Symbol +// (rb_to_symbol_type — a Symbol passes, anything else must answer #to_sym WITH +// a Symbol), then look the name up, raising ArgumentError for an unknown one. +func (vm *VM) symbolToEventFlag(v object.Value) traceEvents { + name := vm.toSymbolName(v) + for _, ev := range traceEventNames { + if ev.name == name { + return ev.bits + } + } + raise("ArgumentError", "unknown event: %s", name) + return 0 +} + +// toSymbolName is rb_to_symbol_type: the Symbol's name, converting through +// #to_sym when the value is not already one. A String converts too (String has +// #to_sym); anything whose #to_sym answers a non-Symbol is a TypeError, which +// is the second half of new_spec's "not a string/symbol" example. +func (vm *VM) toSymbolName(v object.Value) string { + if s, ok := v.(object.Symbol); ok { + return string(s) + } + if vm.respondsToDynamic(v, "to_sym") { + if s, ok := vm.send(v, "to_sym", nil, nil).(object.Symbol); ok { + return string(s) + } + } + raise("TypeError", "no implicit conversion of %s into Symbol", classNameOf(v)) + return "" +} + +// currentTraceArg is get_trace_arg (vm_trace.c:846): the event being delivered, +// or RuntimeError "access from outside" when no handler is running. +func (vm *VM) currentTraceArg() *traceArg { + if vm.traceArg == nil { + raise("RuntimeError", "access from outside") + } + return vm.traceArg +} + +// traceArgBinding is rb_tracearg_binding (vm_trace.c:981). A native-frame event +// has no Ruby frame to bind, and neither does an event fired without one. +func (vm *VM) traceArgBinding(a *traceArg) object.Value { + if a.env == nil { + return object.NilV + } + markEnvCaptured(a.env) + return &Binding{env: a.env, self: a.self, definee: a.definee, file: a.file, + names: append([]string(nil), a.locals...)} +} + +// traceArgParameters is rb_tracearg_parameters (vm_trace.c:916): the call and +// return events (and their block forms) report the running ISeq's parameters, +// with a non-lambda block reporting its positionals as :opt; every other event +// raises. +func traceArgParameters(a *traceArg) object.Value { + switch a.event { + case evCall, evReturn, evBCall, evBReturn: + if a.iseq == nil { + return object.NilV + } + return procParameters(a.iseq, !a.isProc) + case evCCall, evCReturn: + return object.NilV + } + raise("RuntimeError", "not supported by this event") + return object.NilV +} + +// tracePointInspect is tracepoint_inspect (vm_trace.c:1521). Outside a handler +// it says only whether the TracePoint is on; inside one it describes the event, +// in four shapes MRI distinguishes by event. +func (vm *VM) tracePointInspect(tp *tracePoint) string { + a := vm.traceArg + if a == nil { + if tp.tracing { + return "#" + } + return "#" + } + switch a.event { + case evLine: + // A line inside a method names it; a line at top level or in a block that + // belongs to no method has no method_id and falls through to the default. + if a.methodID != "" { + return fmt.Sprintf("#", a.path, a.line, a.methodID) + } + case evCall, evCCall, evReturn, evCReturn: + return fmt.Sprintf("#", eventName(a.event), a.methodID, a.path, a.line) + case evThreadBegin, evThreadEnd: + return fmt.Sprintf("#", eventName(a.event), a.self.Inspect()) + } + return fmt.Sprintf("#", eventName(a.event), a.path, a.line) +} + +// tracePointEnableM is tracepoint_enable_m (vm_trace.c:1375). +func (vm *VM) tracePointEnableM(tp *tracePoint, args []object.Value, blk *Proc) object.Value { + previous := tp.tracing + target, targetLine, targetThread, threadGiven := vm.tracePointEnableKeywords(args) + + // target_thread: defaults to the CURRENT thread, but only for the block form + // with neither target: nor target_line:; every other shape defaults to no + // filter at all. + if !threadGiven { + if blk != nil && target == nil && targetLine == nil { + targetThread = vm.currentThread + } else { + targetThread = nil + } + } + if targetThread != nil && !object.IsNil(targetThread) { + th, ok := targetThread.(*RThread) + if !ok { + raise("TypeError", "wrong argument type %s (expected Thread)", classNameOf(targetThread)) + } + if tp.targetTh != nil { + raise("ArgumentError", "can not override target_thread filter") + } + tp.targetTh = th + } else { + tp.targetTh = nil + } + + if target == nil || object.IsNil(target) { + if targetLine != nil && !object.IsNil(targetLine) { + raise("ArgumentError", "only target_line is specified") + } + vm.tracepointEnable(tp) + } else { + vm.tracepointEnableForTarget(tp, target, targetLine) + } + + if blk == nil { + return object.Bool(previous) + } + return vm.traceScopedBlock(tp, previous, blk) +} + +// tracePointDisableM is tracepoint_disable_m (vm_trace.c:1424). +func (vm *VM) tracePointDisableM(tp *tracePoint, blk *Proc) object.Value { + previous := tp.tracing + if blk == nil { + vm.tracepointDisable(tp) + return object.Bool(previous) + } + if tp.localSeqs != nil { + raise("ArgumentError", "can't disable a targeting TracePoint in a block") + } + vm.tracepointDisable(tp) + return vm.traceScopedBlock(tp, previous, blk) +} + +// traceScopedBlock runs the block form of enable/disable: yield with NO +// arguments (both specs assert the block receives none) and restore the +// PREVIOUS tracing state on the way out, however the block leaves — MRI's +// rb_ensure(rb_yield, …, previous_tracing ? enable : disable, tpval). +func (vm *VM) traceScopedBlock(tp *tracePoint, previous bool, blk *Proc) object.Value { + defer func() { + if previous { + // Re-enabling a TracePoint that the block left targeting would raise, so + // the restore drops any local target first, as MRI's rb_tracepoint_enable + // cannot be reached with local_target_set still set. + tp.localSeqs, tp.targetLine = nil, 0 + vm.tracepointEnable(tp) + } else { + vm.tracepointDisable(tp) + } + }() + return vm.callBlock(blk, nil) +} + +// tracePointEnableKeywords picks enable's three keywords out of a trailing Hash. +// threadGiven distinguishes `target_thread: nil` (no filter) from an absent +// keyword (MRI's sym_default sentinel), which choose differently. +func (vm *VM) tracePointEnableKeywords(args []object.Value) (target, targetLine, targetThread object.Value, threadGiven bool) { + h := trailingKwHash(args) + if h == nil { + return nil, nil, nil, false + } + if v, ok := h.Get(object.SymVal("target")); ok { + target = v + } + if v, ok := h.Get(object.SymVal("target_line")); ok { + targetLine = v + } + if v, ok := h.Get(object.SymVal("target_thread")); ok { + targetThread, threadGiven = v, true + } + return target, targetLine, targetThread, threadGiven +} + +// tracepointEnable is rb_tracepoint_enable (vm_trace.c:1193): registering a +// TracePoint that is already targeting is the nest-enable ArgumentError, and +// re-enabling an already-enabled one is a no-op. +func (vm *VM) tracepointEnable(tp *tracePoint) { + if tp.localSeqs != nil { + raise("ArgumentError", "can't nest-enable a targeting TracePoint") + } + if tp.tracing { + return + } + tp.tracing = true + vm.tracePoints = append(vm.tracePoints, tp) + vm.recomputeTraceEvents() +} + +// tracepointDisable is rb_tracepoint_disable: unregister, and drop any local +// target with it (MRI's disable_local_event_iseq_i sweep clears +// local_target_set, so a disabled TracePoint can be enabled either way again). +func (vm *VM) tracepointDisable(tp *tracePoint) { + tp.localSeqs, tp.targetLine = nil, 0 + if !tp.tracing { + return + } + tp.tracing = false + for i, other := range vm.tracePoints { + if other == tp { + vm.tracePoints = append(vm.tracePoints[:i:i], vm.tracePoints[i+1:]...) + break + } + } + vm.recomputeTraceEvents() +} + +// recomputeTraceEvents refreshes the single word the interpreter loop tests. +// It is the whole of rbgo's answer to MRI's ruby_vm_event_enabled_global_flags: +// zero means no hook wants anything, and every hot-path gate is a test of it. +func (vm *VM) recomputeTraceEvents() { + var all traceEvents + for _, tp := range vm.tracePoints { + all |= tp.events + } + vm.traceEvents = all & supportedTraceEvents +} + +// tracepointEnableForTarget is rb_tracepoint_enable_for_target (vm_trace.c:1234). +func (vm *VM) tracepointEnableForTarget(tp *tracePoint, target, targetLine object.Value) { + iseq, isMethod := vm.iseqOfTarget(target) + if tp.tracing { + raise("ArgumentError", "can't nest-enable a targeting TracePoint") + } + line := 0 + if targetLine != nil && !object.IsNil(targetLine) { + if tp.events&evLine == 0 { + raise("ArgumentError", "target_line is specified, but line event is not specified") + } + line = int(vm.toIntCoerce(targetLine)) + } + + set := map[*bytecode.ISeq]bool{} + collectISeqs(iseq, set) + n := 0 + for s := range set { + // The root of a Method/UnboundMethod target is the only ISeq that can raise + // a :call/:return; every nested one is a block body. + producible := evBCall | evBReturn | evLine + if s == iseq && isMethod { + producible = evCall | evReturn | evLine + } + switch { + case line != 0: + // With target_line: only :line events count, and only on a line this + // ISeq actually has. + if s.HasLine(line) { + n++ + } + case tp.events&producible != 0: + n++ + } + } + if n == 0 { + raise("ArgumentError", "can not enable any hooks") + } + + tp.localSeqs = set + tp.targetLine = line + tp.tracing = true + vm.tracePoints = append(vm.tracePoints, tp) + vm.recomputeTraceEvents() +} + +// iseqOfTarget is iseq_of (vm_trace.c:1217), which MRI writes as +// `RubyVM::InstructionSequence.of(target)`: a Method, an UnboundMethod or a +// Proc yields its code, and anything else is the "specified target is not +// supported" ArgumentError. isMethod reports the first two, whose code can +// raise :call/:return rather than :b_call/:b_return. +func (vm *VM) iseqOfTarget(target object.Value) (iseq *bytecode.ISeq, isMethod bool) { + // methodISeq (method.go) answers the code behind a method record — its own, + // or its block's for a define_method body, and nil for a native, which is + // what `InstructionSequence.of` answers for a C method. + switch t := target.(type) { + case *BoundMethod: + if t.m != nil { + iseq, isMethod = methodISeq(t.m), true + } + case *UnboundMethod: + if t.m != nil { + iseq, isMethod = methodISeq(t.m), true + } + case *Proc: + iseq = t.iseq + } + if iseq == nil { + raise("ArgumentError", "specified target is not supported") + } + return iseq, isMethod +} + +// collectISeqs gathers an ISeq and everything nested inside it. This is +// rb_iseq_add_local_tracepoint_recursively (iseq.c:3912), which walks the child +// ISeqs so a `target:` covers the blocks written inside the target — the reason +// `trace.enable(target: obj.method(:foo))` sees the :b_call of a block three +// levels deep in foo but not one in a method foo merely calls. +func collectISeqs(s *bytecode.ISeq, into map[*bytecode.ISeq]bool) { + if s == nil || into[s] { + return + } + into[s] = true + for _, c := range s.Children { + collectISeqs(c, into) + } +} + +// traceAllowReentry is tracepoint_allow_reentry (vm_trace.c:1607): clear the +// reentry guard for the block, and put it back afterwards whatever happens. +func (vm *VM) traceAllowReentry(blk *Proc) object.Value { + a := vm.traceArg + if a == nil { + raise("RuntimeError", "No need to allow reentrance.") + } + if blk == nil { + raise("ArgumentError", "must be called with a block") + } + vm.traceArg = nil + defer func() { vm.traceArg = a }() + return vm.callBlock(blk, nil) +} + +// fireTrace delivers one event to every hook that wants it. It is +// rb_exec_event_hooks + exec_hooks_body (vm_trace.c:412/:341) with MRI's three +// filters and — crucially — MRI's reentry guard: while vm.traceArg is set no +// further event is delivered, so a handler's own line/call/return events do not +// re-enter it. +// +// ORDER is observable, and it is not registration order. MRI keeps two lists +// and walks them in turn: vm_trace_hook (vm_insnhelper.c v3_4_0:7054) runs the +// GLOBAL hooks first and the ISeq's LOCAL hooks — the ones a `target:` attached +// — second. Within each list hook_list_connect (vm_trace.c) PREPENDS +// (`hook->next = list->hooks; list->hooks = hook`), so the most recently +// enabled hook is called first. ruby/spec pins all three combinations that +// distinguishes (enable_spec.rb:377/:399/:421): a targeting TracePoint enabled +// inside a global one reports [:outer, :inner], two targeting ones report +// [:inner, :outer], and a global one inside a targeting one reports +// [:inner, :outer] again — which no single ordering of one list can produce. +// +// The list is copied before iterating because a handler may enable or disable a +// TracePoint, which rewrites vm.tracePoints underneath the loop; MRI gets the +// same protection from its deleted-flag-plus-sweep scheme (clean_hooks_check). +func (vm *VM) fireTrace(a *traceArg) { + if vm.traceArg != nil || len(vm.tracePoints) == 0 { + return + } + list := append([]*tracePoint(nil), vm.tracePoints...) + vm.traceArg = a + defer func() { vm.traceArg = nil }() + // Pass 0: the global hooks. Pass 1: the local (targeting) ones. Both + // backwards, which is MRI's prepend read forwards. + for pass := 0; pass < 2; pass++ { + wantLocal := pass == 1 + for i := len(list) - 1; i >= 0; i-- { + tp := list[i] + if (tp.localSeqs != nil) != wantLocal { + continue + } + if !tp.tracing || tp.events&a.event == 0 { + continue + } + if tp.targetTh != nil && tp.targetTh != vm.currentThread { + continue + } + if wantLocal && (a.iseq == nil || !tp.localSeqs[a.iseq]) { + continue + } + if tp.targetLine != 0 && tp.targetLine != a.line { + continue + } + vm.callBlock(tp.blk, []object.Value{tp.self}) + } + } +} + +// frameTracePath is the path a frame's events report. An ISeq loaded from a +// file names it; the top-level program's ISeq carries no file, and MRI still +// reports the script there (rb_iseq_path over the main iseq), which is what +// frameFileLabel answers. +func (vm *VM) frameTracePath(iseq *bytecode.ISeq, frame int) string { + if iseq != nil && iseq.File != "" { + return iseq.File + } + return vm.frameFileLabel(frame) +} + +// traceLineEvent fires :line. MRI reports the CURRENT source line here (the +// LINE event is not in get_path_and_lineno's first_lineno set, vm_trace.c:694), +// which is the line the instruction about to run belongs to. +func (vm *VM) traceLineEvent(f *traceFrame, pc int) { + vm.fireTrace(&traceArg{ + event: evLine, + self: f.self, + path: vm.frameTracePath(f.iseq, f.frame), + line: f.iseq.LineAt(pc), + methodID: f.methodID, + calleeID: f.calleeID, + klass: f.klassValue(), + iseq: f.iseq, + isProc: f.isProc, + env: f.env, + definee: f.definee, + locals: f.locals, + file: f.file, + }) +} + +// traceFrame is the slice of an exec frame the event sites need. It is built +// once per traced frame so the three fire sites (entry, line, exit) do not each +// re-derive it, and it is built ONLY when tracing is on. +type traceFrame struct { + iseq *bytecode.ISeq + frame int + self object.Value + methodID string + calleeID string + definee *RClass + env *Env + locals []string + file string + isProc bool + + // entry/exit are the pair of events this frame raises when it starts and + // finishes: call/return, b_call/b_return or class/end. Zero for a frame that + // raises neither (top level, eval). + entry traceEvents + exit traceEvents +} + +// klassValue renders defined_class: the owner of a method frame, nil elsewhere. +func (f *traceFrame) klassValue() object.Value { + if f.definee == nil || f.entry != evCall { + return nil + } + return f.definee +} + +// newTraceFrame decides what a frame is — method, block, class body, or none of +// those — and packages what its events report. +// +// The mapping is MRI's frame typing: a method activation raises call/return, a +// block activation (lambda or not) raises b_call/b_return, and a class or +// module body raises class/end. A top-level or eval frame raises neither. +func newTraceFrame(iseq *bytecode.ISeq, frame int, self object.Value, fm frameMethod, + definee *RClass, env *Env, file string, selfBlock *Proc, methodName string, classBody bool, +) *traceFrame { + f := &traceFrame{ + iseq: iseq, frame: frame, self: self, + methodID: fm.orig, calleeID: fm.callee, + definee: definee, env: env, locals: iseq.Locals, file: file, + } + switch { + case selfBlock != nil: + f.entry, f.exit = evBCall, evBReturn + f.isProc = !selfBlock.isLambda + case classBody: + f.entry, f.exit = evClass, evEnd + // A class body is not a method: it reports no method_id / callee_id, as + // rb_vm_control_frame_id_and_class finds no method entry for a CLASS frame. + f.methodID, f.calleeID = "", "" + case methodName != "": + f.entry, f.exit = evCall, evReturn + } + return f +} + +// traceEntryEvent fires :call / :b_call / :class. MRI reports the ISeq's +// FIRST line for all three, not the current one (get_path_and_lineno, +// vm_trace.c:694, singles out CLASS|CALL|B_CALL) — so a :call event names the +// `def` line however far into the body the pc has moved. +func (vm *VM) traceEntryEvent(f *traceFrame) { + vm.fireTrace(&traceArg{ + event: f.entry, + self: f.self, + path: vm.frameTracePath(f.iseq, f.frame), + line: f.iseq.FirstLine, + methodID: f.methodID, + calleeID: f.calleeID, + klass: f.klassValue(), + iseq: f.iseq, + isProc: f.isProc, + env: f.env, + definee: f.definee, + locals: f.locals, + file: f.file, + }) +} + +// traceExitEvent fires :return / :b_return / :end, carrying the frame's value +// as #return_value for the two return forms. +func (vm *VM) traceExitEvent(f *traceFrame, result object.Value) { + vm.fireTrace(&traceArg{ + event: f.exit, + self: f.self, + path: vm.frameTracePath(f.iseq, f.frame), + line: vm.frameLine(f.frame), + methodID: f.methodID, + calleeID: f.calleeID, + klass: f.klassValue(), + iseq: f.iseq, + isProc: f.isProc, + hasData: f.exit != evEnd, + data: result, + env: f.env, + definee: f.definee, + locals: f.locals, + file: f.file, + }) +} + +// traceRescueEvent fires :rescue as a frame's rescue clause takes over. MRI +// raises it from vm_insnhelper's exception path with the exception as #data +// (rb_tracearg_raised_exception accepts RAISE and RESCUE alike). +func (vm *VM) traceRescueEvent(f *traceFrame, exc object.Value) { + vm.fireTrace(&traceArg{ + event: evRescue, + self: f.self, + path: vm.frameTracePath(f.iseq, f.frame), + line: vm.frameLine(f.frame), + methodID: f.methodID, + calleeID: f.calleeID, + iseq: f.iseq, + isProc: f.isProc, + hasData: true, + data: exc, + env: f.env, + definee: f.definee, + locals: f.locals, + file: f.file, + }) +} + +// traceEventsNamed renders an event mask for a diagnostic. It exists for the +// tests, which assert the name table round-trips rather than re-listing it. +func traceEventsNamed(e traceEvents) string { + var names []string + for _, ev := range traceEventNames { + // Single bits only: the aggregates would double-count. + if ev.bits&(ev.bits-1) == 0 && e&ev.bits != 0 { + names = append(names, ev.name) + } + } + return strings.Join(names, ",") +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 8242fdd6..7430600c 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -633,6 +633,49 @@ type VM struct { // a define_method body) still gets. GVL-guarded. sendNoKW bool + // traceEvents is the union of the event masks of every ENABLED TracePoint, + // narrowed to the events rbgo raises. It is the one word the interpreter loop + // tests, and it is zero for every program that never builds a TracePoint — so + // the whole of the tracing machinery costs an untraced program a single field + // load and a not-taken branch per instruction. + // + // It stands for MRI's ruby_vm_event_enabled_global_flags, but it is asked a + // different question. MRI does not test a flag per instruction at all: its + // compiler emits distinct `trace` instructions and rb_iseq_trace_set (iseq.c + // v3_4_0:3981) rewrites their encodings when a hook turns an event on, so an + // untraced program executes no trace instruction whatsoever. rbgo has no such + // instruction to rewrite (its ISeqs are plain []Instr, shared and reachable + // from AOT-frozen output), so the gate is a test instead of a rewrite. Keeping + // it a SINGLE word — rather than "is the hook list empty", which would be a + // slice header and a length — is what keeps that test to one load. + // GVL-guarded. + traceEvents traceEvents + + // tracePoints is the enabled hook list, in the order the hooks were enabled, + // which is the order they are called in (MRI appends to rb_hook_list_t and + // walks it forwards). GVL-guarded. + tracePoints []*tracePoint + + // traceArg is the event currently being delivered, or nil. It is MRI's + // ec->trace_arg and carries both of that field's jobs: it is what every + // TracePoint accessor reads (and whose absence is the RuntimeError "access + // from outside"), and it is the REENTRY GUARD — while it is set, fireTrace + // delivers nothing, so a handler's own line, call and return events do not + // re-enter it (rb_exec_event_hooks, vm_trace.c v3_4_0:434). + // GVL-guarded. + traceArg *traceArg + + // cTracePoint is the TracePoint class, kept so TracePoint.new on the class + // itself does not have to go back through the constant table. + cTracePoint *RClass + + // pendingClassBody marks the next exec frame as a class/module body, so it + // raises MRI's :class and :end events rather than being taken for the + // top-level or an eval. Like pendingMethodCtx it is set immediately before the + // dispatch and read-and-cleared at the top of exec, before any user code runs. + // GVL-guarded. + pendingClassBody bool + // children records finished synthetic child processes (Process.spawn / // Kernel.fork), so Process.waitpid2 can report each one's exit status. // childPidSeq assigns the next synthetic pid. GVL-guarded. @@ -962,6 +1005,7 @@ func New(out io.Writer) *VM { vm.registerOstruct() // OpenStruct data ops (to_h/inspect/dig/delete_field/==), backed by go-ruby-ostruct; after the prelude so it reopens the prelude-defined class vm.registerLogger() // Logger (require "logger"), backed by go-ruby-logger; after the prelude so Logger::Error etc. can subclass the exception hierarchy vm.registerPStore() // PStore (require "pstore"), backed by go-ruby-pstore; after the prelude so PStore::Error < StandardError + vm.registerTracePoint() // TracePoint (see tracepoint.go); after the prelude so it subclasses the prelude-visible Object vm.includeMySQLEnumerable() // Mysql2::Result mixes in Enumerable; after the prelude so the module exists vm.includeWeakMapEnumerable() // ObjectSpace::WeakMap mixes in Enumerable; after the prelude so the module exists vm.includeStringIOEnumerable() // StringIO mixes in Enumerable; after the prelude so the module exists @@ -1285,6 +1329,11 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // (self's singleton class), applied to methodDefinee once the frame is set up. pendEvalDef := vm.pendingEvalMethodDefinee vm.pendingEvalMethodDefinee = nil + // Consumed here for the same reason: the flag says THIS frame is a class or + // module body (see pendingClassBody), and it must not survive into a nested + // frame that argument binding might start. + pendClassBody := vm.pendingClassBody + vm.pendingClassBody = false var fm frameMethod switch { case selfBlock != nil: @@ -1618,6 +1667,22 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, }() } + // TracePoint: describe this frame once, and raise its entry event (:call, + // :b_call or :class) before the first instruction runs. tf stays nil — and + // nothing below is allocated or computed — for every program with no enabled + // TracePoint, which is the gate traceEvents exists to be. + // + // The entry event is raised HERE, after the frame's name/file/code/cref + // stacks are pushed and lexCref is settled, because a handler may take a + // backtrace or ask for #binding, and both read that state. + var tf *traceFrame + if vm.traceEvents != 0 { + tf = newTraceFrame(iseq, myFrame, self, fm, definee, env, iseq.File, selfBlock, methodName, pendClassBody) + if tf.entry&vm.traceEvents != 0 { + vm.traceEntryEvent(tf) + } + } + pc := 0 var handlers []handlerFrame result := object.Value(object.NilV) @@ -1661,6 +1726,25 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, if myFrame < len(vm.frameCode) { vm.frameCode[myFrame].pc = pc } + // TracePoint's :line event. This is the ONE per-instruction cost the + // whole subsystem adds to an untraced program: a load of vm.traceEvents + // and a not-taken branch. Everything past it — the line-map search, the + // event record, the hook walk — is behind the gate. It is deliberately + // the first thing after the pc is published, because a handler placed + // here reads that pc for its #lineno. + if vm.traceEvents&evLine != 0 { + // tf is nil when this frame started before any TracePoint was + // enabled and one was enabled from inside it — `trace.enable` with + // no block, whose very next line must already be traced. Building it + // here rather than per-frame is what keeps the untraced cost to the + // branch above. + if tf == nil { + tf = newTraceFrame(iseq, myFrame, self, fm, definee, env, iseq.File, selfBlock, methodName, pendClassBody) + } + if iseq.IsLineStart(pc) { + vm.traceLineEvent(tf, pc) + } + } in := iseq.Insns[pc] switch in.Op { case bytecode.OpNop: @@ -2078,6 +2162,9 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // reopened one. MRI resets scope visibility to public on every class // frame push (vm_insnhelper.c), mirroring defineClassIn/defineModuleIn. sc.defaultVis, sc.funcMode = visPublic, false + // A class/module body raises MRI's :class and :end events; the flag is + // consumed at the top of exec (see pendingClassBody). + vm.pendingClassBody = true push(vm.exec(iseq.Children[in.A], sc, nil, sc, "", nil, nil, nil, nil, nil)) case bytecode.OpAlias: vm.aliasMethod(methodDefinee, iseq.Names[in.A], iseq.Names[in.B]) @@ -2550,6 +2637,21 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, vm.curExc = exc push(exc) pc = h.pc + // TracePoint's :rescue event: MRI raises it as the rescue clause + // takes over, with the exception as #raised_exception + // (rb_tracearg_raised_exception accepts RAISE and RESCUE alike, + // vm_trace.c v3_4_0:1021). The pc is published first so a handler + // asking for #lineno is answered with the clause's line, not the + // abandoned raise site. + if vm.traceEvents&evRescue != 0 { + if myFrame < len(vm.frameCode) { + vm.frameCode[myFrame].pc = pc + } + if tf == nil { + tf = newTraceFrame(iseq, myFrame, self, fm, definee, env, iseq.File, selfBlock, methodName, pendClassBody) + } + vm.traceRescueEvent(tf, exc) + } }() runChunk() }() @@ -2563,6 +2665,20 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // defer — the common case (fib, dispatch, accessors) skips that overhead. runChunk() } + // TracePoint's exit event (:return, :b_return or :end), raised on NORMAL + // completion only — an exception unwinds straight past here, and MRI likewise + // does not report a return for a frame that raised. It runs before the frame + // stacks are popped so #lineno, #path and a handler's backtrace still see this + // frame, and before putEnv so a handler asking for #binding pins an Env that + // is still this frame's. + if vm.traceEvents != 0 { + if tf == nil { + tf = newTraceFrame(iseq, myFrame, self, fm, definee, env, iseq.File, selfBlock, methodName, pendClassBody) + } + if tf.exit&vm.traceEvents != 0 { + vm.traceExitEvent(tf, result) + } + } // Recycle this frame's env and operand stack on normal completion (putEnv is // a no-op if a closure captured the env). An exception unwinding past here // skips recycling and leaves both to the GC — correct, just not pooled. @@ -2728,6 +2844,9 @@ func (vm *VM) defineClassIn(parent *RClass, name string, body *bytecode.ISeq, su // module_function mode (MRI resets these on every (re)open). class.defaultVis, class.funcMode = visPublic, false adoptReopenLexParent(class, parent, scoped) + // A class body raises MRI's :class and :end events; the flag is consumed at + // the top of exec (see pendingClassBody). + vm.pendingClassBody = true return vm.exec(body, class, nil, class, "", nil, nil, nil, nil, nil) } @@ -2771,6 +2890,9 @@ func (vm *VM) defineModuleIn(parent *RClass, name string, body *bytecode.ISeq, s } mod.defaultVis, mod.funcMode = visPublic, false adoptReopenLexParent(mod, parent, scoped) + // A module body raises MRI's :class and :end events; the flag is consumed at + // the top of exec (see pendingClassBody). + vm.pendingClassBody = true return vm.exec(body, mod, nil, mod, "", nil, nil, nil, nil, nil) } From 7ab48a0750af166d610aea3b9e78419ab50327b4 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 25 Sep 2026 10:25:27 +0200 Subject: [PATCH 2/6] vm: whitebox tests for TracePoint, and a bounds guard on the event path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every branch of tracepoint.go, driven through Ruby where Ruby can reach it and called directly where it cannot — the tpOf refusals, the non-Thread and already-set target_thread: refusals, allow_reentry's block check, the C-call inspect shape, the reentry guard, and the frame-typing table. frameTracePath now clamps its frame index: frameFileLabel indexes the frame stacks unguarded, and exec can legitimately outrun them (the bounds note at the pc publication says why), so an event raised from a frame the stacks no longer cover reported a panic rather than the script. scriptLabel is frameFileLabel's no-file answer, split out so the clamped path can reach it with no frame at all. Measured beside the specs: 15 differential probes (events, accessors, inspect shapes, enable/disable returns, target:/target_line: including every refusal, hook order, allow_reentry, thread filter, rescue) are byte-identical to ruby 4.0.5. That second witness matters here because the corpus shim defines ShouldProxy#equal but not #equal?, so a spec written `x.should.equal?(y)` asserts nothing. Co-Authored-By: Claude Opus 5 --- internal/vm/tracepoint.go | 23 + internal/vm/tracepoint_internal_test.go | 748 ++++++++++++++++++++++++ 2 files changed, 771 insertions(+) create mode 100644 internal/vm/tracepoint_internal_test.go diff --git a/internal/vm/tracepoint.go b/internal/vm/tracepoint.go index e4e1c8d7..45951ce4 100644 --- a/internal/vm/tracepoint.go +++ b/internal/vm/tracepoint.go @@ -745,9 +745,32 @@ func (vm *VM) frameTracePath(iseq *bytecode.ISeq, frame int) string { if iseq != nil && iseq.File != "" { return iseq.File } + // frameFileLabel indexes the frame stacks unguarded, and exec can legitimately + // outrun them (see the bounds note at the pc publication in exec): a frame no + // longer covered reports the script, which is what frameFileLabel itself falls + // back to for a frame with no file. + if frame < 0 || frame >= len(vm.frameFiles) { + frame = len(vm.frameFiles) - 1 + } + if frame < 0 { + return vm.scriptLabel() + } return vm.frameFileLabel(frame) } +// scriptLabel is frameFileLabel's answer for a frame with no file of its own, +// reached directly when there is no frame at all to ask. +func (vm *VM) scriptLabel() string { + switch vm.scriptName { + case "": + return "(rbgo)" + case "-e": + return "-e" + default: + return vm.scriptName + } +} + // traceLineEvent fires :line. MRI reports the CURRENT source line here (the // LINE event is not in get_path_and_lineno's first_lineno set, vm_trace.c:694), // which is the line the instruction about to run belongs to. diff --git a/internal/vm/tracepoint_internal_test.go b/internal/vm/tracepoint_internal_test.go new file mode 100644 index 00000000..11d9ba39 --- /dev/null +++ b/internal/vm/tracepoint_internal_test.go @@ -0,0 +1,748 @@ +package vm + +import ( + "bytes" + "strings" + "testing" + + "github.com/go-embedded-ruby/ruby/internal/bytecode" + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// tpRun runs a Ruby program and returns its trimmed stdout, failing the test on +// an uncaught exception. It is runSrc with the error surfaced as the test +// failure message rather than a bare "run:" so a TracePoint mistake reads. +func tpRun(t *testing.T, src string) string { + t.Helper() + return runSrc(t, src) +} + +// TestTracePointLineEvents pins the shape the whole subsystem rests on: a :line +// event per line of the enabled block, reporting the line about to run. +func TestTracePointLineEvents(t *testing.T) { + got := tpRun(t, ` +lines = [] +TracePoint.new(:line) { |tp| lines << tp.lineno }.enable do + a = 1 + b = 2 +end +p lines +`) + if got != "[4, 5]" { + t.Errorf("line events = %s, want [4, 5]", got) + } +} + +// TestTracePointEnableWithoutBlock covers the no-block form of enable/disable: +// each answers the PREVIOUS state, and a TracePoint enabled with no block keeps +// tracing the frame it was enabled from — which is the branch that builds the +// traceFrame lazily, because that frame started untraced. +func TestTracePointEnableWithoutBlock(t *testing.T) { + got := tpRun(t, ` +called = false +t = TracePoint.new(:line) { called = true } +p t.enable +x = 1 +p called +p t.enable +p t.disable +p t.disable +`) + want := "false\ntrue\ntrue\ntrue\nfalse" + if got != want { + t.Errorf("enable/disable = %q, want %q", got, want) + } +} + +// TestTracePointNewValidation covers tracepoint_new_s's three refusals and its +// no-argument default (every event). +func TestTracePointNewValidation(t *testing.T) { + got := tpRun(t, ` +begin; TracePoint.new(:nope) {}; rescue ArgumentError => e; puts e.message; end +begin; TracePoint.new(:line); rescue ArgumentError => e; puts e.message; end +begin; TracePoint.new(Object.new) {}; rescue TypeError; puts "TypeError"; end +p TracePoint.new {}.enabled? +`) + want := "unknown event: nope\nmust be called with a block\nTypeError\nfalse" + if got != want { + t.Errorf("validation = %q, want %q", got, want) + } +} + +// TestTracePointEventCoercion covers rb_to_symbol_type's two live paths: a +// String (which answers #to_sym with a Symbol) and an object whose #to_sym +// answers something else, which is a TypeError. +func TestTracePointEventCoercion(t *testing.T) { + got := tpRun(t, ` +p TracePoint.new("line") {}.enabled? +class Bad; def to_sym; 1; end; end +begin; TracePoint.new(Bad.new) {}; rescue TypeError; puts "TypeError"; end +`) + want := "false\nTypeError" + if got != want { + t.Errorf("coercion = %q, want %q", got, want) + } +} + +// TestTracePointCallReturn covers the :call/:return pair and the accessors that +// only those events answer: method_id, callee_id (which differ under an alias), +// defined_class, return_value and parameters. +func TestTracePointCallReturn(t *testing.T) { + got := tpRun(t, ` +class Holder + def m(a, b = 1); a; end + alias_method :m2, :m +end +seen = [] +TracePoint.new(:call, :return) do |tp| + seen << [tp.event, tp.method_id, tp.callee_id, tp.defined_class.to_s, tp.parameters] + seen << tp.return_value if tp.event == :return +end.enable { Holder.new.m2(7) } +seen.each { |s| p s } +`) + want := strings.Join([]string{ + `[:call, :m, :m2, "Holder", [[:req, :a], [:opt, :b]]]`, + `[:return, :m, :m2, "Holder", [[:req, :a], [:opt, :b]]]`, + `7`, + }, "\n") + if got != want { + t.Errorf("call/return = %q, want %q", got, want) + } +} + +// TestTracePointBlockEvents covers :b_call/:b_return and the non-lambda +// #parameters rule (a proc reports its positionals as :opt, a lambda as :req). +func TestTracePointBlockEvents(t *testing.T) { + got := tpRun(t, ` +seen = [] +pr = proc { |x| } +la = ->(y) { } +tp = TracePoint.new(:b_call) { |t| seen << t.parameters } +tp.enable(target: pr) { pr.call(1) } +tp.enable(target: la) { la.call(1) } +p seen +`) + if got != "[[[:opt, :x]], [[:req, :y]]]" { + t.Errorf("block parameters = %s, want [[[:opt, :x]], [[:req, :y]]]", got) + } +} + +// TestTracePointClassEnd covers the :class/:end pair for a class body, a module +// body and a singleton-class body — the three sites that mark a frame as one. +func TestTracePointClassEnd(t *testing.T) { + got := tpRun(t, ` +seen = [] +TracePoint.new(:class, :end) { |tp| seen << [tp.event, tp.self.to_s] }.enable do + class TPC; end + module TPM; end + o = Object.new + class << o; end +end +seen.each { |s| p s[0] } +p seen[0][1] +`) + want := ":class\n:end\n:class\n:end\n:class\n:end\n\"TPC\"" + if got != want { + t.Errorf("class/end = %q, want %q", got, want) + } +} + +// TestTracePointRescue covers the :rescue event and #raised_exception. +func TestTracePointRescue(t *testing.T) { + got := tpRun(t, ` +seen = nil +TracePoint.new(:rescue) { |tp| seen = tp.raised_exception.class.to_s }.enable do + begin + raise ArgumentError, "boom" + rescue => e + end +end +p seen +`) + if got != `"ArgumentError"` { + t.Errorf("rescue = %s, want \"ArgumentError\"", got) + } +} + +// TestTracePointAccessorsOutside covers get_trace_arg's refusal and the two +// accessors that refuse an event that does not carry their datum. +func TestTracePointAccessorsOutside(t *testing.T) { + got := tpRun(t, ` +tp = TracePoint.new(:line) {} +begin; tp.lineno; rescue RuntimeError => e; puts e.message; end +TracePoint.new(:line) do |t| + begin; t.return_value; rescue RuntimeError => e; puts e.message; end + begin; t.raised_exception; rescue RuntimeError => e; puts e.message; end + begin; t.parameters; rescue RuntimeError => e; puts e.message; end + t.disable +end.enable { x = 1 } +`) + want := strings.Join([]string{ + "access from outside", + "not supported by this event", + "not supported by this event", + "not supported by this event", + }, "\n") + if got != want { + t.Errorf("outside access = %q, want %q", got, want) + } +} + +// TestTracePointInspectShapes covers tracepoint_inspect's four live shapes: the +// enabled/disabled form, the bare event form, the "in 'method'" form a :line +// inside a method takes, and the quoted-method form of :call. +func TestTracePointInspectShapes(t *testing.T) { + got := tpRun(t, ` +t = TracePoint.new(:line) {} +p t.inspect +t.enable +p t.inspect +t.disable +seen = [] +def tp_inspect_probe; 42; end +TracePoint.new(:line, :call) { |tp| seen << tp.inspect }.enable { tp_inspect_probe } +puts seen.map { |s| s.sub(/:\d+>/, ':N>').sub(/ [^ ]*:N>/, ' F:N>') }.uniq +`) + lines := strings.Split(got, "\n") + if len(lines) < 2 || lines[0] != `"#"` || lines[1] != `"#"` { + t.Fatalf("inspect enabled/disabled = %q", got) + } + joined := strings.Join(lines[2:], "\n") + for _, want := range []string{"# e + e.message +end +p msg { TracePoint.new(:line) {}.enable(target_line: 3) {} } +p msg { TracePoint.new(:call) {}.enable(target: Object.new) {} } +p msg { TracePoint.new(:call) {}.enable(target: proc {}) {} } +p msg { TracePoint.new(:call) {}.enable(target_line: 1, target: -> {}) {} } +p msg { TracePoint.new(:line) {}.enable(target_line: 1, target: -> {}) {} } +p msg { TracePoint.new(:line) {}.enable(target_line: -2, target: -> {}) {} } +p msg { TracePoint.new(:line) {}.enable(target_line: Object.new, target: -> {}) {} } +t = TracePoint.new(:b_call) {} +p msg { t.enable(target: -> {}) { t.enable(target: -> {}) {} } } +p msg { t.enable { t.enable(target: -> {}) {} } } +p msg { t.enable(target: -> {}) { t.enable {} } } +p msg { t.enable(target: -> {}) { t.disable {} } } +`) + want := strings.Join([]string{ + `"only target_line is specified"`, + `"specified target is not supported"`, + `"can not enable any hooks"`, + `"target_line is specified, but line event is not specified"`, + `"can not enable any hooks"`, + `"can not enable any hooks"`, + `"no implicit conversion of Object into Integer"`, + `"can't nest-enable a targeting TracePoint"`, + `"can't nest-enable a targeting TracePoint"`, + `"can't nest-enable a targeting TracePoint"`, + `"can't disable a targeting TracePoint in a block"`, + }, "\n") + if got != want { + t.Errorf("target errors =\n%s\nwant\n%s", got, want) + } +} + +// TestTracePointTargetScoping covers the working target: paths — a Method, an +// UnboundMethod and a Proc — and the rule that a target does NOT reach the +// methods it calls. +func TestTracePointTargetScoping(t *testing.T) { + got := tpRun(t, ` +seen = [] +o = Object.new +def o.foo; bar; end +def o.bar; end +TracePoint.new(:call) { |tp| seen << tp.method_id }.enable(target: o.method(:foo)) { o.foo } +p seen + +seen = [] +k = Class.new { def foo; end } +TracePoint.new(:call) { |tp| seen << tp.method_id }.enable(target: k.instance_method(:foo)) { k.new.foo } +p seen + +seen = [] +blk = proc { } +TracePoint.new(:b_call) { |tp| seen << tp.event }.enable(target: blk) { blk.call } +p seen +`) + want := "[:foo]\n[:foo]\n[:b_call]" + if got != want { + t.Errorf("target scoping = %q, want %q", got, want) + } +} + +// TestTracePointTargetLine covers the target_line: filter: only that line is +// delivered, and a value that answers #to_int is accepted. +func TestTracePointTargetLine(t *testing.T) { + got := tpRun(t, ` +target = lambda do + x = 1 + y = 2 + z = x + y +end +all = [] +TracePoint.new(:line) { |tp| all << tp.lineno }.enable(target: target) { target.call } +mid = all[1] +seen = [] +TracePoint.new(:line) { |tp| seen << tp.lineno }.enable(target_line: mid, target: target) { target.call } +p seen == [mid] +class Coerce; def initialize(n); @n = n; end; def to_int; @n; end; end +seen = [] +TracePoint.new(:line) { |tp| seen << tp.lineno }.enable(target_line: Coerce.new(mid), target: target) { target.call } +p seen == [mid] +`) + if got != "true\ntrue" { + t.Errorf("target_line = %q, want \"true\\ntrue\"", got) + } +} + +// TestTracePointAllowReentry covers TracePoint.allow_reentry in both of its +// states: inside a handler it lifts the reentry guard for its block, and +// outside one it refuses. +func TestTracePointAllowReentry(t *testing.T) { + got := tpRun(t, ` +begin + TracePoint.allow_reentry {} +rescue RuntimeError => e + puts e.message +end +n = 0 +tp = TracePoint.new(:line) do |t| + n += 1 + next if n > 3 + TracePoint.allow_reentry { q = 1 } +end +tp.enable { w = 1 } +p n > 1 +`) + want := "No need to allow reentrance.\ntrue" + if got != want { + t.Errorf("allow_reentry = %q, want %q", got, want) + } +} + +// TestTracePointTraceAndThreadFilter covers TracePoint.trace (which hands back +// an already-enabled TracePoint) and the target_thread: default, which confines +// the block form of enable to the thread that called it. +func TestTracePointTraceAndThreadFilter(t *testing.T) { + got := tpRun(t, ` +t = TracePoint.trace(:line) {} +p t.enabled? +t.disable + +threads = [] +main = Thread.current +TracePoint.new(:line) { threads << Thread.current }.enable do + x = 1 + th = Thread.new { y = 2 } + th.join +end +p threads.uniq == [main] +`) + want := "true\ntrue" + if got != want { + t.Errorf("trace/thread filter = %q, want %q", got, want) + } +} + +// TestTracePointThreadFilterOverride covers the "can not override +// target_thread filter" refusal, which needs a TracePoint that already carries +// one when a second enable names another. +func TestTracePointThreadFilterOverride(t *testing.T) { + vm := New(&bytes.Buffer{}) + tp := &tracePoint{events: evLine, blk: &Proc{}, targetTh: vm.mainThread} + defer func() { + r := recover() + e, ok := r.(RubyError) + if !ok || e.Class != "ArgumentError" { + t.Fatalf("recover = %v, want an ArgumentError", r) + } + if e.Message != "can not override target_thread filter" { + t.Errorf("message = %q", e.Message) + } + }() + h := object.NewHash() + h.Set(object.SymVal("target_thread"), vm.mainThread) + vm.tracePointEnableM(tp, []object.Value{h}, nil) +} + +// TestTracePointThreadFilterType covers the non-Thread target_thread: refusal. +func TestTracePointThreadFilterType(t *testing.T) { + vm := New(&bytes.Buffer{}) + tp := &tracePoint{events: evLine, blk: &Proc{}} + defer func() { + e, ok := recover().(RubyError) + if !ok || e.Class != "TypeError" { + t.Fatalf("want a TypeError, got %v", e) + } + }() + h := object.NewHash() + h.Set(object.SymVal("target_thread"), object.IntValue(1)) + vm.tracePointEnableM(tp, []object.Value{h}, nil) +} + +// TestTracePointBadReceiver covers tpOf's two refusals: a receiver that is not +// an RObject at all, and one that is but carries no state. +func TestTracePointBadReceiver(t *testing.T) { + for _, v := range []object.Value{object.IntValue(1), &RObject{ivars: map[string]object.Value{}}} { + func() { + defer func() { + e, ok := recover().(RubyError) + if !ok || e.Class != "TypeError" { + t.Errorf("tpOf(%v) = %v, want a TypeError", v, e) + } + }() + tpOf(v) + }() + } +} + +// TestTracePointAllowReentryNeedsBlock covers allow_reentry's block check, +// which Ruby cannot reach: the method is only callable from inside a handler, +// and a handler calling it without a block is what this asserts. +func TestTracePointAllowReentryNeedsBlock(t *testing.T) { + vm := New(&bytes.Buffer{}) + vm.traceArg = &traceArg{event: evLine} + defer func() { + e, ok := recover().(RubyError) + if !ok || e.Class != "ArgumentError" { + t.Fatalf("want an ArgumentError, got %v", e) + } + }() + vm.traceAllowReentry(nil) +} + +// TestTracePointEventNameUnknown covers get_event_id's default: a mask that is +// not one named event has no name. +func TestTracePointEventNameUnknown(t *testing.T) { + if got := eventName(evLine | evCall); got != "unknown" { + t.Errorf("eventName(line|call) = %q, want %q", got, "unknown") + } + if got := eventName(evRescue); got != "rescue" { + t.Errorf("eventName(rescue) = %q, want %q", got, "rescue") + } +} + +// TestTracePointEventsNamed covers the diagnostic renderer, which must list +// single bits only — an aggregate would double-count its members. +func TestTracePointEventsNamed(t *testing.T) { + if got := traceEventsNamed(evLine | evBReturn); got != "line,b_return" { + t.Errorf("traceEventsNamed = %q, want %q", got, "line,b_return") + } + if got := traceEventsNamed(0); got != "" { + t.Errorf("traceEventsNamed(0) = %q, want empty", got) + } +} + +// TestTracePointClassOf covers tpClassOf's fallback: TracePoint.new reached +// with a non-class receiver still makes a TracePoint. +func TestTracePointClassOf(t *testing.T) { + vm := New(&bytes.Buffer{}) + if got := tpClassOf(vm, object.IntValue(1)); got != vm.cTracePoint { + t.Errorf("tpClassOf(non-class) = %v, want TracePoint", got) + } + if got := tpClassOf(vm, vm.cObject); got != vm.cObject { + t.Errorf("tpClassOf(Object) = %v, want Object", got) + } +} + +// TestTraceArgBindingNoFrame covers rb_tracearg_binding's nil answer for an +// event that carries no frame. +func TestTraceArgBindingNoFrame(t *testing.T) { + vm := New(&bytes.Buffer{}) + if got := vm.traceArgBinding(&traceArg{event: evLine}); !object.IsNil(got) { + t.Errorf("binding with no env = %v, want nil", got) + } +} + +// TestTraceArgParametersEdges covers the two #parameters answers Ruby cannot +// currently reach: a frame event with no ISeq, and the C-call events, which +// answer nil rather than raising. +func TestTraceArgParametersEdges(t *testing.T) { + if got := traceArgParameters(&traceArg{event: evCall}); !object.IsNil(got) { + t.Errorf("parameters with no iseq = %v, want nil", got) + } + if got := traceArgParameters(&traceArg{event: evCCall}); !object.IsNil(got) { + t.Errorf("parameters for c_call = %v, want nil", got) + } +} + +// TestTracePointInspectThreadEvent covers tracepoint_inspect's thread shape, +// which rbgo does not raise (thread_begin/thread_end are not in +// supportedTraceEvents) but whose rendering is defined. +func TestTracePointInspectThreadEvent(t *testing.T) { + vm := New(&bytes.Buffer{}) + tp := &tracePoint{events: evThreadBegin} + vm.traceArg = &traceArg{event: evThreadBegin, self: vm.mainThread} + got := vm.tracePointInspect(tp) + if !strings.HasPrefix(got, "# {} +out = [] +outer = TracePoint.new(:b_call) { out << :outer } +inner = TracePoint.new(:b_call) { out << :inner } +outer.enable(target: target) { inner.enable(target: target) { target.call } } +p out +out = [] +outer.enable(target: target) { inner.enable { target.call } } +p out.last(2) +`) + want := "[:inner, :outer]\n[:inner, :outer]" + if got != want { + t.Errorf("hook order = %q, want %q", got, want) + } +} From 0bf8d66014e447dc5f3eb514e2c09ab8619c6445 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 25 Sep 2026 10:32:29 +0200 Subject: [PATCH 3/6] vm: drop the TracePoint branches production cannot reach Three pieces claimed something nothing could check, so they are gone rather than covered by a test written to reach them: - traceEventsNamed rendered an event mask for no caller but its own test; - #parameters carried MRI's C-call arm (rb_unnamed_parameters over the method arity), and rbgo raises no :c_call; - #inspect carried MRI's C-call and thread shapes, and rbgo raises none of :c_call, :c_return, :thread_begin or :thread_end. Each is noted where it was, so it comes back WITH its event rather than ahead of it. traceArg.hasData went too: it was written at two sites and read at none. Re-measured after: core/tracepoint still 69, the 15 differential probes still byte-identical to ruby 4.0.5. Co-Authored-By: Claude Opus 5 --- internal/vm/tracepoint.go | 38 ++++++++------------- internal/vm/tracepoint_internal_test.go | 44 +++++-------------------- 2 files changed, 21 insertions(+), 61 deletions(-) diff --git a/internal/vm/tracepoint.go b/internal/vm/tracepoint.go index 45951ce4..60de1fa8 100644 --- a/internal/vm/tracepoint.go +++ b/internal/vm/tracepoint.go @@ -39,7 +39,6 @@ package vm import ( "fmt" - "strings" "github.com/go-embedded-ruby/ruby/internal/bytecode" "github.com/go-embedded-ruby/ruby/internal/object" @@ -194,8 +193,8 @@ type traceArg struct { iseq *bytecode.ISeq // the code the event came from; also the local-hook key isProc bool // a non-lambda block frame: #parameters reports :opt - hasData bool - data object.Value // return_value / raised_exception + // data is #return_value or #raised_exception, for the events that carry one. + data object.Value env *Env definee *RClass @@ -401,6 +400,10 @@ func (vm *VM) traceArgBinding(a *traceArg) object.Value { // return events (and their block forms) report the running ISeq's parameters, // with a non-lambda block reporting its positionals as :opt; every other event // raises. +// +// MRI's C-call arm (rb_unnamed_parameters over the method's arity) is NOT +// transcribed: rbgo raises no :c_call, so that answer could never be checked +// against anything. Add it with the event, not before. func traceArgParameters(a *traceArg) object.Value { switch a.event { case evCall, evReturn, evBCall, evBReturn: @@ -408,16 +411,18 @@ func traceArgParameters(a *traceArg) object.Value { return object.NilV } return procParameters(a.iseq, !a.isProc) - case evCCall, evCReturn: - return object.NilV } raise("RuntimeError", "not supported by this event") return object.NilV } // tracePointInspect is tracepoint_inspect (vm_trace.c:1521). Outside a handler -// it says only whether the TracePoint is on; inside one it describes the event, -// in four shapes MRI distinguishes by event. +// it says only whether the TracePoint is on; inside one it describes the event. +// +// MRI distinguishes four shapes; the two here are the ones rbgo can reach. Its +// C-call shape is the same as :call/:return's, and its thread shape belongs to +// :thread_begin/:thread_end — neither of which rbgo raises, so neither is +// transcribed. Add each with its event. func (vm *VM) tracePointInspect(tp *tracePoint) string { a := vm.traceArg if a == nil { @@ -433,10 +438,8 @@ func (vm *VM) tracePointInspect(tp *tracePoint) string { if a.methodID != "" { return fmt.Sprintf("#", a.path, a.line, a.methodID) } - case evCall, evCCall, evReturn, evCReturn: + case evCall, evReturn: return fmt.Sprintf("#", eventName(a.event), a.methodID, a.path, a.line) - case evThreadBegin, evThreadEnd: - return fmt.Sprintf("#", eventName(a.event), a.self.Inspect()) } return fmt.Sprintf("#", eventName(a.event), a.path, a.line) } @@ -886,7 +889,6 @@ func (vm *VM) traceExitEvent(f *traceFrame, result object.Value) { klass: f.klassValue(), iseq: f.iseq, isProc: f.isProc, - hasData: f.exit != evEnd, data: result, env: f.env, definee: f.definee, @@ -908,7 +910,6 @@ func (vm *VM) traceRescueEvent(f *traceFrame, exc object.Value) { calleeID: f.calleeID, iseq: f.iseq, isProc: f.isProc, - hasData: true, data: exc, env: f.env, definee: f.definee, @@ -916,16 +917,3 @@ func (vm *VM) traceRescueEvent(f *traceFrame, exc object.Value) { file: f.file, }) } - -// traceEventsNamed renders an event mask for a diagnostic. It exists for the -// tests, which assert the name table round-trips rather than re-listing it. -func traceEventsNamed(e traceEvents) string { - var names []string - for _, ev := range traceEventNames { - // Single bits only: the aggregates would double-count. - if ev.bits&(ev.bits-1) == 0 && e&ev.bits != 0 { - names = append(names, ev.name) - } - } - return strings.Join(names, ",") -} diff --git a/internal/vm/tracepoint_internal_test.go b/internal/vm/tracepoint_internal_test.go index 11d9ba39..2e545cfd 100644 --- a/internal/vm/tracepoint_internal_test.go +++ b/internal/vm/tracepoint_internal_test.go @@ -450,17 +450,6 @@ func TestTracePointEventNameUnknown(t *testing.T) { } } -// TestTracePointEventsNamed covers the diagnostic renderer, which must list -// single bits only — an aggregate would double-count its members. -func TestTracePointEventsNamed(t *testing.T) { - if got := traceEventsNamed(evLine | evBReturn); got != "line,b_return" { - t.Errorf("traceEventsNamed = %q, want %q", got, "line,b_return") - } - if got := traceEventsNamed(0); got != "" { - t.Errorf("traceEventsNamed(0) = %q, want empty", got) - } -} - // TestTracePointClassOf covers tpClassOf's fallback: TracePoint.new reached // with a non-class receiver still makes a TracePoint. func TestTracePointClassOf(t *testing.T) { @@ -482,37 +471,20 @@ func TestTraceArgBindingNoFrame(t *testing.T) { } } -// TestTraceArgParametersEdges covers the two #parameters answers Ruby cannot -// currently reach: a frame event with no ISeq, and the C-call events, which -// answer nil rather than raising. -func TestTraceArgParametersEdges(t *testing.T) { +// TestTraceArgParametersNoISeq covers the one #parameters answer Ruby cannot +// reach: a frame event with no ISeq behind it. +func TestTraceArgParametersNoISeq(t *testing.T) { if got := traceArgParameters(&traceArg{event: evCall}); !object.IsNil(got) { t.Errorf("parameters with no iseq = %v, want nil", got) } - if got := traceArgParameters(&traceArg{event: evCCall}); !object.IsNil(got) { - t.Errorf("parameters for c_call = %v, want nil", got) - } -} - -// TestTracePointInspectThreadEvent covers tracepoint_inspect's thread shape, -// which rbgo does not raise (thread_begin/thread_end are not in -// supportedTraceEvents) but whose rendering is defined. -func TestTracePointInspectThreadEvent(t *testing.T) { - vm := New(&bytes.Buffer{}) - tp := &tracePoint{events: evThreadBegin} - vm.traceArg = &traceArg{event: evThreadBegin, self: vm.mainThread} - got := vm.tracePointInspect(tp) - if !strings.HasPrefix(got, "# Date: Fri, 25 Sep 2026 10:45:25 +0200 Subject: [PATCH 4/6] vm: raise the exit event for a frame that does not finish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A differential probe against ruby 4.0.5 found it: rbgo raised :return only on normal completion, and MRI raises it for a frame an exception unwinds past as well. `def a2; raise; end` called from a1 under a :return TracePoint reports [:return, :a2, nil] then [:return, :a1, nil] in MRI; rbgo reported neither. Same for a non-local return out of a block, which MRI reports as the block's :b_return. MRI's rule, read off hook_before_rewind and confirmed by measuring: a frame that leaves by unwinding reports #return_value as nil, the frame that CATCHES a non-local return reports the returned value, and a block broken out of reports the break value. Three sites, all gated on tracing being on: - a deferred fire for the exception/throw case, registered BEFORE the returnSignal recover so it RUNS after it and sees exitFired set; - the returnSignal recover itself, before it truncates this frame's own stack entries away; - OpBreak and the non-local arm of OpReturn, which know their value and must fire before they panic. exitFired keeps it to exactly one event per frame. Probes: 22 now, all byte-identical to ruby 4.0.5. core/tracepoint stays 69 — no spec exercises an unwinding return, which is why only a probe could find this. Co-Authored-By: Claude Opus 5 --- internal/bytecode/tracelines_test.go | 32 +++++++ internal/vm/tracepoint_internal_test.go | 122 ++++++++++++++++++++++++ internal/vm/vm.go | 50 +++++++++- 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 internal/bytecode/tracelines_test.go diff --git a/internal/bytecode/tracelines_test.go b/internal/bytecode/tracelines_test.go new file mode 100644 index 00000000..9d19c56b --- /dev/null +++ b/internal/bytecode/tracelines_test.go @@ -0,0 +1,32 @@ +package bytecode + +import "testing" + +// TestIsLineStart covers the exact-hit search: a pc that begins a line run says +// yes, a pc inside one says no, and an ISeq with no map — or a pc before its +// first entry — says no rather than reaching for Lines[0]. +func TestIsLineStart(t *testing.T) { + is := &ISeq{Lines: []LineEntry{{PC: 2, Line: 10}, {PC: 5, Line: 11}, {PC: 9, Line: 12}}} + for pc, want := range map[int]bool{0: false, 1: false, 2: true, 3: false, 4: false, 5: true, 8: false, 9: true, 40: false} { + if got := is.IsLineStart(pc); got != want { + t.Errorf("IsLineStart(%d) = %v, want %v", pc, got, want) + } + } + // An ISeq compiled with no position information at all (a prelude frozen + // before the line map existed) begins no line anywhere. + empty := &ISeq{} + if empty.IsLineStart(0) { + t.Error("IsLineStart on an ISeq with no line map must be false") + } +} + +// TestHasLine covers the target_line: membership question, whose false answer +// is MRI's "can not enable any hooks". +func TestHasLine(t *testing.T) { + is := &ISeq{Lines: []LineEntry{{PC: 0, Line: 10}, {PC: 4, Line: 12}}} + for line, want := range map[int]bool{10: true, 11: false, 12: true, 0: false, -2: false} { + if got := is.HasLine(line); got != want { + t.Errorf("HasLine(%d) = %v, want %v", line, got, want) + } + } +} diff --git a/internal/vm/tracepoint_internal_test.go b/internal/vm/tracepoint_internal_test.go index 2e545cfd..c321f1eb 100644 --- a/internal/vm/tracepoint_internal_test.go +++ b/internal/vm/tracepoint_internal_test.go @@ -718,3 +718,125 @@ p out.last(2) t.Errorf("hook order = %q, want %q", got, want) } } + +// TestTracePointPathAndNilMethodID covers the #path accessor and the nil +// #method_id / #callee_id a line event outside any method reports. +func TestTracePointPathAndNilMethodID(t *testing.T) { + got := tpRun(t, ` +seen = nil +TracePoint.new(:line) { |tp| seen = [tp.path, tp.method_id, tp.callee_id] }.enable { x = 1 } +p seen[1..2] +p seen[0].is_a?(String) +`) + if got != "[nil, nil]\ntrue" { + t.Errorf("path/method_id = %q, want %q", got, "[nil, nil]\ntrue") + } +} + +// TestTracePointScopedRestore covers traceScopedBlock's previous-state restore +// in BOTH directions: a scoped disable on an enabled TracePoint leaves it +// enabled, and a scoped enable on a disabled one leaves it disabled. +func TestTracePointScopedRestore(t *testing.T) { + got := tpRun(t, ` +t = TracePoint.new(:line) {} +t.enable +p t.disable { t.enabled? } +p t.enabled? +t.disable +p t.enable { t.enabled? } +p t.enabled? +p t.disable { |*a| a } +`) + want := "false\ntrue\ntrue\nfalse\n[]" + if got != want { + t.Errorf("scoped restore = %q, want %q", got, want) + } +} + +// TestTracePointUnwindExit covers the exit events MRI raises for a frame that +// does NOT finish: an exception rewinding past two methods, a non-local return +// out of a block, and a break out of one (whose :b_return carries the value). +func TestTracePointUnwindExit(t *testing.T) { + got := tpRun(t, ` +def a2; raise "x"; end +def a1; a2; end +ev = [] +TracePoint.new(:return) { |tp| ev << [tp.method_id, tp.return_value] }.enable do + begin; a1; rescue; end +end +p ev + +def early; [1, 2, 3].each { |i| return i if i == 2 }; :no; end +ev = [] +TracePoint.new(:return, :b_return) { |tp| ev << [tp.event, tp.method_id, tp.return_value] }.enable { early } +p ev + +def brk; [1, 2, 3].each { |i| break i if i == 2 }; end +ev = [] +TracePoint.new(:b_return) { |tp| ev << tp.return_value }.enable { brk } +p ev +`) + want := strings.Join([]string{ + `[[:a2, nil], [:a1, nil]]`, + // The trailing entry of each is the `enable` block's own b_return, which + // MRI raises too: the hook is installed before the block is yielded. + `[[:b_return, :early, nil], [:b_return, :early, nil], [:return, :early, 2], [:b_return, nil, 2]]`, + `[nil, 2, 2]`, + }, "\n") + if got != want { + t.Errorf("unwind exits =\n%s\nwant\n%s", got, want) + } +} + +// TestTracePointEnsureReturn covers the local-return-through-an-ensure route, +// which reaches the frame's own returnSignal recover rather than the normal +// exit — the value must still be the returned one. +func TestTracePointEnsureReturn(t *testing.T) { + got := tpRun(t, ` +def ens; begin; return 5; ensure; end; end +ev = [] +TracePoint.new(:return) { |tp| ev << [tp.method_id, tp.return_value] }.enable { ens } +p ev +`) + if got != "[[:ens, 5]]" { + t.Errorf("ensure return = %s, want [[:ens, 5]]", got) + } +} + +// TestFireTraceSkipsOtherEvents covers the per-hook event filter inside the +// walk: a hook registered for another event is stepped over rather than called. +func TestFireTraceSkipsOtherEvents(t *testing.T) { + vm := New(&bytes.Buffer{}) + var order []string + mk := func(name string, ev traceEvents) *tracePoint { + tp := &tracePoint{events: ev, tracing: true, self: object.NilV} + tp.blk = &Proc{native: func(*VM, []object.Value) object.Value { order = append(order, name); return object.NilV }} + return tp + } + other := mk("other", evCall) + wanted := mk("wanted", evLine) + stale := mk("stale", evLine) + stale.tracing = false + vm.tracePoints = []*tracePoint{other, wanted, stale} + vm.fireTrace(&traceArg{event: evLine}) + if len(order) != 1 || order[0] != "wanted" { + t.Errorf("fireTrace called %v, want only [wanted]", order) + } +} + +// TestScriptLabel covers the two fallbacks frameFileLabel names when a frame +// carries no file of its own and there is no frame to ask. +func TestScriptLabel(t *testing.T) { + vm := New(&bytes.Buffer{}) + if got := vm.scriptLabel(); got != "(rbgo)" { + t.Errorf("scriptLabel with no script = %q, want %q", got, "(rbgo)") + } + vm.SetScriptName("-e") + if got := vm.scriptLabel(); got != "-e" { + t.Errorf("scriptLabel for -e = %q, want %q", got, "-e") + } + vm.SetScriptName("prog.rb") + if got := vm.scriptLabel(); got != "prog.rb" { + t.Errorf("scriptLabel = %q, want %q", got, "prog.rb") + } +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 7430600c..c7219322 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -1334,6 +1334,10 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // frame that argument binding might start. pendClassBody := vm.pendingClassBody vm.pendingClassBody = false + // This frame's TracePoint description, built lazily: nil unless tracing is on + // (see the entry-event site below). Declared here because the exit-event + // defers registered further down close over it. + var tf *traceFrame var fm frameMethod switch { case selfBlock != nil: @@ -1638,6 +1642,30 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, methodDefinee = pendEvalDef } + // TracePoint's exit event (:return / :b_return / :end) for a frame that does + // NOT finish: an exception, a throw, or a non-local return passing through. + // + // MRI raises it there too — hook_before_rewind fires RETURN as it rewinds the + // frames an exception is unwinding past — and reports #return_value as nil, + // because the frame produced none. `def a2; raise; end` called from a1 under + // a :return TracePoint reports [:return, :a2, nil] then [:return, :a1, nil], + // and rbgo reported neither until this defer existed. + // + // It is registered BEFORE the returnSignal recover below so that it RUNS + // AFTER it (defers are LIFO): the recover raises the event itself, with the + // returned value, and sets exitFired so this one stays quiet. exitFired is + // likewise set at the two sites that raise the event before panicking on + // purpose — OpBreak, which carries the break value, and a non-local OpReturn, + // which does not. + exitFired := false + defer func() { + if tf == nil || tf.exit == 0 || exitFired || vm.traceEvents&tf.exit == 0 { + return + } + exitFired = true + vm.traceExitEvent(tf, object.NilV) + }() + // Every frame catches a returnSignal aimed at its own selfTarget (a local // return/next routed through an ensure, or a non-local return whose home is // this frame). A signal for some other target passes through. On a catch the @@ -1650,6 +1678,13 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, if !ok || sig.target != selfTarget { panic(r) } + // The frame IS returning, with sig.value: raise its exit event before + // the truncation below takes this frame's own stack entries away, + // since the event reports its path and line. + if tf != nil && tf.exit != 0 && !exitFired && vm.traceEvents&tf.exit != 0 { + exitFired = true + vm.traceExitEvent(tf, sig.value) + } // Truncate one past this frame's own entries: this frame's normal // pop never ran, and the deeper frames that unwound left theirs too. vm.frameNames = vm.frameNames[:frameNamesDepth-1] @@ -1675,7 +1710,6 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // The entry event is raised HERE, after the frame's name/file/code/cref // stacks are pushed and lexCref is settled, because a handler may take a // backtrace or ask for #binding, and both read that state. - var tf *traceFrame if vm.traceEvents != 0 { tf = newTraceFrame(iseq, myFrame, self, fm, definee, env, iseq.File, selfBlock, methodName, pendClassBody) if tf.entry&vm.traceEvents != 0 { @@ -2402,6 +2436,13 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, vm.raiseWithIvars("LocalJumpError", "unexpected return", map[string]object.Value{"@reason": object.Symbol("return"), "@exit_value": val}) } + // This block frame is leaving for good. MRI reports its :b_return with + // nil — the value belongs to the method the return reaches, which + // reports it as its own :return. + if tf != nil && tf.exit != 0 && !exitFired && vm.traceEvents&tf.exit != 0 { + exitFired = true + vm.traceExitEvent(tf, object.NilV) + } panic(returnSignal{target: t, value: val}) } // An explicit return from this frame with a live ensure handler must @@ -2433,6 +2474,12 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, if selfBlock == nil || !selfBlock.breakLive { raise("LocalJumpError", "break from proc-closure") } + // Unlike a non-local return, MRI reports a broken-out-of block's + // :b_return WITH the break value. + if tf != nil && tf.exit != 0 && !exitFired && vm.traceEvents&tf.exit != 0 { + exitFired = true + vm.traceExitEvent(tf, val) + } panic(breakSignal{owner: selfBlock, value: val}) case bytecode.OpPushHandler: // Operand B=1 marks an ensure handler (runs on every unwind); @@ -2676,6 +2723,7 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, tf = newTraceFrame(iseq, myFrame, self, fm, definee, env, iseq.File, selfBlock, methodName, pendClassBody) } if tf.exit&vm.traceEvents != 0 { + exitFired = true vm.traceExitEvent(tf, result) } } From 61ddcaaff676f417d01dd7f5a090dcfd4bebadf3 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 25 Sep 2026 10:51:21 +0200 Subject: [PATCH 5/6] vm: cover the mid-frame enable, and read target: as MRI's NIL_P MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps the coverage list named, both real behaviour rather than dead code. A TracePoint enabled from INSIDE a frame must still raise that frame's own :rescue and exit events — MRI rewrites the running iseq's trace instructions when the hook goes on, rbgo builds the frame description on first need — and neither lazy rebuild had a test. It does now, measured against ruby 4.0.5 in all three shapes (top level, method, block). enable's target_thread: default tested `target == nil` where MRI tests NIL_P(target), so an explicit `target: nil` lost the current-thread default that MRI keeps. Probe 23 pins both directions: `target: nil` confines the trace to the calling thread, `target_thread: nil` does not. internal/vm and internal/bytecode are back to the below-100 LIST main carries, with no entry added. 24 probes, all byte-identical. Co-Authored-By: Claude Opus 5 --- internal/vm/tracepoint.go | 5 +- internal/vm/tracepoint_internal_test.go | 90 ++++++++++++++++--------- 2 files changed, 63 insertions(+), 32 deletions(-) diff --git a/internal/vm/tracepoint.go b/internal/vm/tracepoint.go index 60de1fa8..32cfbd14 100644 --- a/internal/vm/tracepoint.go +++ b/internal/vm/tracepoint.go @@ -451,9 +451,10 @@ func (vm *VM) tracePointEnableM(tp *tracePoint, args []object.Value, blk *Proc) // target_thread: defaults to the CURRENT thread, but only for the block form // with neither target: nor target_line:; every other shape defaults to no - // filter at all. + // filter at all. MRI tests NIL_P, not "was the keyword written", so an + // explicit `target: nil` counts as no target here too. if !threadGiven { - if blk != nil && target == nil && targetLine == nil { + if blk != nil && object.IsNil(target) && object.IsNil(targetLine) { targetThread = vm.currentThread } else { targetThread = nil diff --git a/internal/vm/tracepoint_internal_test.go b/internal/vm/tracepoint_internal_test.go index c321f1eb..a7d51270 100644 --- a/internal/vm/tracepoint_internal_test.go +++ b/internal/vm/tracepoint_internal_test.go @@ -9,18 +9,10 @@ import ( "github.com/go-embedded-ruby/ruby/internal/object" ) -// tpRun runs a Ruby program and returns its trimmed stdout, failing the test on -// an uncaught exception. It is runSrc with the error surfaced as the test -// failure message rather than a bare "run:" so a TracePoint mistake reads. -func tpRun(t *testing.T, src string) string { - t.Helper() - return runSrc(t, src) -} - // TestTracePointLineEvents pins the shape the whole subsystem rests on: a :line // event per line of the enabled block, reporting the line about to run. func TestTracePointLineEvents(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` lines = [] TracePoint.new(:line) { |tp| lines << tp.lineno }.enable do a = 1 @@ -38,7 +30,7 @@ p lines // tracing the frame it was enabled from — which is the branch that builds the // traceFrame lazily, because that frame started untraced. func TestTracePointEnableWithoutBlock(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` called = false t = TracePoint.new(:line) { called = true } p t.enable @@ -57,7 +49,7 @@ p t.disable // TestTracePointNewValidation covers tracepoint_new_s's three refusals and its // no-argument default (every event). func TestTracePointNewValidation(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` begin; TracePoint.new(:nope) {}; rescue ArgumentError => e; puts e.message; end begin; TracePoint.new(:line); rescue ArgumentError => e; puts e.message; end begin; TracePoint.new(Object.new) {}; rescue TypeError; puts "TypeError"; end @@ -73,7 +65,7 @@ p TracePoint.new {}.enabled? // String (which answers #to_sym with a Symbol) and an object whose #to_sym // answers something else, which is a TypeError. func TestTracePointEventCoercion(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` p TracePoint.new("line") {}.enabled? class Bad; def to_sym; 1; end; end begin; TracePoint.new(Bad.new) {}; rescue TypeError; puts "TypeError"; end @@ -88,7 +80,7 @@ begin; TracePoint.new(Bad.new) {}; rescue TypeError; puts "TypeError"; end // only those events answer: method_id, callee_id (which differ under an alias), // defined_class, return_value and parameters. func TestTracePointCallReturn(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` class Holder def m(a, b = 1); a; end alias_method :m2, :m @@ -113,7 +105,7 @@ seen.each { |s| p s } // TestTracePointBlockEvents covers :b_call/:b_return and the non-lambda // #parameters rule (a proc reports its positionals as :opt, a lambda as :req). func TestTracePointBlockEvents(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` seen = [] pr = proc { |x| } la = ->(y) { } @@ -130,7 +122,7 @@ p seen // TestTracePointClassEnd covers the :class/:end pair for a class body, a module // body and a singleton-class body — the three sites that mark a frame as one. func TestTracePointClassEnd(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` seen = [] TracePoint.new(:class, :end) { |tp| seen << [tp.event, tp.self.to_s] }.enable do class TPC; end @@ -149,7 +141,7 @@ p seen[0][1] // TestTracePointRescue covers the :rescue event and #raised_exception. func TestTracePointRescue(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` seen = nil TracePoint.new(:rescue) { |tp| seen = tp.raised_exception.class.to_s }.enable do begin @@ -167,7 +159,7 @@ p seen // TestTracePointAccessorsOutside covers get_trace_arg's refusal and the two // accessors that refuse an event that does not carry their datum. func TestTracePointAccessorsOutside(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` tp = TracePoint.new(:line) {} begin; tp.lineno; rescue RuntimeError => e; puts e.message; end TracePoint.new(:line) do |t| @@ -192,7 +184,7 @@ end.enable { x = 1 } // enabled/disabled form, the bare event form, the "in 'method'" form a :line // inside a method takes, and the quoted-method form of :call. func TestTracePointInspectShapes(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` t = TracePoint.new(:line) {} p t.inspect t.enable @@ -218,7 +210,7 @@ puts seen.map { |s| s.sub(/:\d+>/, ':N>').sub(/ [^ ]*:N>/, ' F:N>') }.uniq // TestTracePointBinding covers rb_tracearg_binding: a frame event hands back a // Binding over that frame's locals. func TestTracePointBinding(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` def tp_binding_probe; secret = 42; end names = nil TracePoint.new(:return) { |tp| names = tp.binding.local_variables }.enable { tp_binding_probe } @@ -232,7 +224,7 @@ p names // TestTracePointTargetErrors covers every refusal of // rb_tracepoint_enable_for_target and of the nesting rules around it. func TestTracePointTargetErrors(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` def msg yield "no raise" @@ -274,7 +266,7 @@ p msg { t.enable(target: -> {}) { t.disable {} } } // UnboundMethod and a Proc — and the rule that a target does NOT reach the // methods it calls. func TestTracePointTargetScoping(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` seen = [] o = Object.new def o.foo; bar; end @@ -301,7 +293,7 @@ p seen // TestTracePointTargetLine covers the target_line: filter: only that line is // delivered, and a value that answers #to_int is accepted. func TestTracePointTargetLine(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` target = lambda do x = 1 y = 2 @@ -327,7 +319,7 @@ p seen == [mid] // states: inside a handler it lifts the reentry guard for its block, and // outside one it refuses. func TestTracePointAllowReentry(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` begin TracePoint.allow_reentry {} rescue RuntimeError => e @@ -352,7 +344,7 @@ p n > 1 // an already-enabled TracePoint) and the target_thread: default, which confines // the block form of enable to the thread that called it. func TestTracePointTraceAndThreadFilter(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` t = TracePoint.trace(:line) {} p t.enabled? t.disable @@ -688,7 +680,7 @@ func TestTraceFrameKlassValue(t *testing.T) { // TestTracePointDefinedClassNil covers the accessor's nil rendering, which a // non-method event reaches. func TestTracePointDefinedClassNil(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` seen = 1 TracePoint.new(:line) { |tp| seen = tp.defined_class }.enable { x = 1 } p seen @@ -702,7 +694,7 @@ p seen // the targeting ones, each most-recently-enabled first — which is the one // property registration order alone cannot produce. func TestTracePointHookOrder(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` target = -> {} out = [] outer = TracePoint.new(:b_call) { out << :outer } @@ -722,7 +714,7 @@ p out.last(2) // TestTracePointPathAndNilMethodID covers the #path accessor and the nil // #method_id / #callee_id a line event outside any method reports. func TestTracePointPathAndNilMethodID(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` seen = nil TracePoint.new(:line) { |tp| seen = [tp.path, tp.method_id, tp.callee_id] }.enable { x = 1 } p seen[1..2] @@ -737,7 +729,7 @@ p seen[0].is_a?(String) // in BOTH directions: a scoped disable on an enabled TracePoint leaves it // enabled, and a scoped enable on a disabled one leaves it disabled. func TestTracePointScopedRestore(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` t = TracePoint.new(:line) {} t.enable p t.disable { t.enabled? } @@ -757,7 +749,7 @@ p t.disable { |*a| a } // does NOT finish: an exception rewinding past two methods, a non-local return // out of a block, and a break out of one (whose :b_return carries the value). func TestTracePointUnwindExit(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` def a2; raise "x"; end def a1; a2; end ev = [] @@ -792,7 +784,7 @@ p ev // which reaches the frame's own returnSignal recover rather than the normal // exit — the value must still be the returned one. func TestTracePointEnsureReturn(t *testing.T) { - got := tpRun(t, ` + got := runSrc(t, ` def ens; begin; return 5; ensure; end; end ev = [] TracePoint.new(:return) { |tp| ev << [tp.method_id, tp.return_value] }.enable { ens } @@ -840,3 +832,41 @@ func TestScriptLabel(t *testing.T) { t.Errorf("scriptLabel = %q, want %q", got, "prog.rb") } } + +// TestTracePointEnabledMidFrame covers the lazy traceFrame rebuild at the +// rescue and normal-exit sites: a TracePoint enabled from INSIDE a frame must +// still raise that frame's own events, and the frame started with no +// description because nothing was tracing when it began. +// +// MRI gets there by rewriting the running ISeq's trace instructions when the +// hook is enabled; rbgo gets there by building the description on first need. +func TestTracePointEnabledMidFrame(t *testing.T) { + got := runSrc(t, ` +seen = nil +t = TracePoint.new(:rescue) { |tp| seen = tp.raised_exception.class } +t.enable +begin + raise ArgumentError +rescue +end +t.disable +p seen + +$ev = [] +$t = TracePoint.new(:return) { |tp| $ev << [tp.method_id, tp.return_value] if tp.method_id == :turn_on } +def turn_on; $t.enable; 7; end +turn_on +$t.disable +p $ev + +$ev2 = [] +$t2 = TracePoint.new(:b_return) { |tp| $ev2 << tp.return_value } +[1].each { $t2.enable; 9 } +$t2.disable +p $ev2 +`) + want := "ArgumentError\n[[:turn_on, 7]]\n[9]" + if got != want { + t.Errorf("mid-frame enable = %q, want %q", got, want) + } +} From 3fbd51e1677b57cc31469784cbbe42f1468c2383 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Fri, 25 Sep 2026 11:29:35 +0200 Subject: [PATCH 6/6] vm: stop the exit-event defer costing every frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first shape paid per FRAME as well as per instruction: exec registered the unwind exit-event defer unconditionally and its closures captured one more variable. bench/blocks.rb — 10M tiny block calls, this VM's worst case for anything paid per frame, and the bench the frameCode comment already names for exactly that — read +3.65% median / +5.22% best-of against the base binary. Two changes, measured: gate the defer on vm.traceEvents != 0 (an open-coded defer inside an `if` is still open-coded, so an untraced program does not register it), and move the one-shot flag out of exec's locals into the frame description the four raise sites already hold. blocks.rb: +0.75% median / -0.93% best-of. A direct A/B of the two shapes puts the gain at -2.76% median on blocks.rb; dispatch.rb came down with it, +2.60% -> +1.59%. What is left is the per-instruction gate — one load and one not-taken branch — which loop.rb, a single frame and 20M instructions, reads at +0.4% best-of / +1.4% median. The gating costs one corner: a frame already running when the TracePoint was enabled raises no exit event if it then leaves by UNWINDING, because it registered no defer. Its normal exit still does, and that is tested. Re-verified on this tree: full suite green, below-100 coverage list exactly main's, corpus 22296 with the same +69, 24/24 probes byte-identical to ruby 4.0.5. Co-Authored-By: Claude Opus 5 --- internal/vm/tracepoint.go | 10 +++++++++- internal/vm/vm.go | 34 +++++++++++++++++++--------------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/internal/vm/tracepoint.go b/internal/vm/tracepoint.go index 32cfbd14..59e3d6b2 100644 --- a/internal/vm/tracepoint.go +++ b/internal/vm/tracepoint.go @@ -188,7 +188,7 @@ type traceArg struct { line int methodID string // "" renders as nil calleeID string - klass object.Value // defined_class; NilV when the event has none + klass object.Value // defined_class; nil for an event that has none iseq *bytecode.ISeq // the code the event came from; also the local-hook key isProc bool // a non-lambda block frame: #parameters reports :opt @@ -816,6 +816,14 @@ type traceFrame struct { // raises neither (top level, eval). entry traceEvents exit traceEvents + + // exitFired records that the exit event has been raised, so the four sites + // that can raise it — the normal end of exec, the returnSignal recover, a + // non-local OpReturn and OpBreak — produce exactly one between them. It lives + // here rather than beside them in exec because exec's closures would then + // capture one more variable per frame, which bench/blocks.rb (millions of + // tiny block calls) can measure. + exitFired bool } // klassValue renders defined_class: the owner of a method frame, nil elsewhere. diff --git a/internal/vm/vm.go b/internal/vm/vm.go index c7219322..bb86e1ee 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -1657,14 +1657,18 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // likewise set at the two sites that raise the event before panicking on // purpose — OpBreak, which carries the break value, and a non-local OpReturn, // which does not. - exitFired := false - defer func() { - if tf == nil || tf.exit == 0 || exitFired || vm.traceEvents&tf.exit == 0 { - return - } - exitFired = true - vm.traceExitEvent(tf, object.NilV) - }() + // Conditional on purpose: registering it is free for an untraced program + // (an open-coded defer is a bit in a mask, not a call), and NOT registering + // it keeps exec's frame free of a closure it would never run. + if vm.traceEvents != 0 { + defer func() { + if tf == nil || tf.exit == 0 || tf.exitFired || vm.traceEvents&tf.exit == 0 { + return + } + tf.exitFired = true + vm.traceExitEvent(tf, object.NilV) + }() + } // Every frame catches a returnSignal aimed at its own selfTarget (a local // return/next routed through an ensure, or a non-local return whose home is @@ -1681,8 +1685,8 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // The frame IS returning, with sig.value: raise its exit event before // the truncation below takes this frame's own stack entries away, // since the event reports its path and line. - if tf != nil && tf.exit != 0 && !exitFired && vm.traceEvents&tf.exit != 0 { - exitFired = true + if tf != nil && tf.exit != 0 && !tf.exitFired && vm.traceEvents&tf.exit != 0 { + tf.exitFired = true vm.traceExitEvent(tf, sig.value) } // Truncate one past this frame's own entries: this frame's normal @@ -2439,8 +2443,8 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, // This block frame is leaving for good. MRI reports its :b_return with // nil — the value belongs to the method the return reaches, which // reports it as its own :return. - if tf != nil && tf.exit != 0 && !exitFired && vm.traceEvents&tf.exit != 0 { - exitFired = true + if tf != nil && tf.exit != 0 && !tf.exitFired && vm.traceEvents&tf.exit != 0 { + tf.exitFired = true vm.traceExitEvent(tf, object.NilV) } panic(returnSignal{target: t, value: val}) @@ -2476,8 +2480,8 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, } // Unlike a non-local return, MRI reports a broken-out-of block's // :b_return WITH the break value. - if tf != nil && tf.exit != 0 && !exitFired && vm.traceEvents&tf.exit != 0 { - exitFired = true + if tf != nil && tf.exit != 0 && !tf.exitFired && vm.traceEvents&tf.exit != 0 { + tf.exitFired = true vm.traceExitEvent(tf, val) } panic(breakSignal{owner: selfBlock, value: val}) @@ -2723,7 +2727,7 @@ func (vm *VM) exec(iseq *bytecode.ISeq, self object.Value, args []object.Value, tf = newTraceFrame(iseq, myFrame, self, fm, definee, env, iseq.File, selfBlock, methodName, pendClassBody) } if tf.exit&vm.traceEvents != 0 { - exitFired = true + tf.exitFired = true vm.traceExitEvent(tf, result) } }