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/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.go b/internal/vm/tracepoint.go new file mode 100644 index 00000000..59e3d6b2 --- /dev/null +++ b/internal/vm/tracepoint.go @@ -0,0 +1,928 @@ +// 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" + + "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; 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 + + // data is #return_value or #raised_exception, for the events that carry one. + data object.Value + + 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. +// +// 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: + if a.iseq == nil { + return object.NilV + } + return procParameters(a.iseq, !a.isProc) + } + 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. +// +// 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 { + 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, evReturn: + return fmt.Sprintf("#", eventName(a.event), a.methodID, a.path, a.line) + } + 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. 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 && object.IsNil(target) && object.IsNil(targetLine) { + 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 + } + // 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. +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 + + // 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. +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, + 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, + data: exc, + env: f.env, + definee: f.definee, + locals: f.locals, + file: f.file, + }) +} diff --git a/internal/vm/tracepoint_internal_test.go b/internal/vm/tracepoint_internal_test.go new file mode 100644 index 00000000..a7d51270 --- /dev/null +++ b/internal/vm/tracepoint_internal_test.go @@ -0,0 +1,872 @@ +package vm + +import ( + "bytes" + "strings" + "testing" + + "github.com/go-embedded-ruby/ruby/internal/bytecode" + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// 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 := runSrc(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 := runSrc(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 := 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 +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 := 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 +`) + 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 := runSrc(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 := runSrc(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 := runSrc(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 := runSrc(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 := runSrc(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 := runSrc(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 := runSrc(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 := runSrc(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 := runSrc(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 := runSrc(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") + } +} + +// 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) + } +} + +// 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) + } +} + +// TestTracePointInspectReturnShape covers the quoted-method inspect shape +// directly, with a synthetic event so the path and line are fixed. +func TestTracePointInspectReturnShape(t *testing.T) { + vm := New(&bytes.Buffer{}) + vm.traceArg = &traceArg{event: evReturn, methodID: "max", path: "a.rb", line: 3} + want := "#" + if got := vm.tracePointInspect(&tracePoint{}); got != want { + t.Errorf("inspect = %q, want %q", got, want) + } +} + +// TestTracePointDisplayMarkers covers the object.Value markers on the Go state +// box, which is never a receiver and so never renders through Ruby. +func TestTracePointDisplayMarkers(t *testing.T) { + tp := &tracePoint{} + if tp.ToS() != "#" || tp.Inspect() != "#" || !tp.Truthy() { + t.Errorf("markers = %q/%q/%v", tp.ToS(), tp.Inspect(), tp.Truthy()) + } +} + +// TestFireTraceReentryGuard covers the guard directly: with an event in flight +// nothing is delivered, and with no hooks at all there is nothing to deliver. +func TestFireTraceReentryGuard(t *testing.T) { + vm := New(&bytes.Buffer{}) + fired := 0 + tp := &tracePoint{events: evLine, tracing: true} + tp.blk = &Proc{native: func(*VM, []object.Value) object.Value { fired++; return object.NilV }} + tp.self = object.NilV + vm.tracePoints = []*tracePoint{tp} + + vm.traceArg = &traceArg{event: evLine} + vm.fireTrace(&traceArg{event: evLine}) + if fired != 0 { + t.Errorf("fired %d times while an event was in flight, want 0", fired) + } + vm.traceArg = nil + vm.fireTrace(&traceArg{event: evLine}) + if fired != 1 { + t.Errorf("fired %d times, want 1", fired) + } + // A hook whose thread filter names another thread is skipped. + tp.targetTh = &RThread{} + vm.fireTrace(&traceArg{event: evLine}) + if fired != 1 { + t.Errorf("fired %d times through a foreign thread filter, want 1", fired) + } + // And an empty list short-circuits before the copy. + vm.tracePoints = nil + vm.fireTrace(&traceArg{event: evLine}) + if fired != 1 { + t.Errorf("fired %d times with no hooks, want 1", fired) + } +} + +// TestTracepointEnableIdempotent covers rb_tracepoint_enable's no-op branch and +// rb_tracepoint_disable's, neither of which changes the hook list. +func TestTracepointEnableIdempotent(t *testing.T) { + vm := New(&bytes.Buffer{}) + tp := &tracePoint{events: evLine, blk: &Proc{}} + vm.tracepointEnable(tp) + vm.tracepointEnable(tp) + if len(vm.tracePoints) != 1 { + t.Errorf("hook list = %d entries after a double enable, want 1", len(vm.tracePoints)) + } + if vm.traceEvents != evLine { + t.Errorf("traceEvents = %#x, want %#x", vm.traceEvents, evLine) + } + vm.tracepointDisable(tp) + vm.tracepointDisable(tp) + if len(vm.tracePoints) != 0 || vm.traceEvents != 0 { + t.Errorf("after disable: %d hooks, events %#x", len(vm.tracePoints), vm.traceEvents) + } +} + +// TestRecomputeTraceEventsMasksUnsupported pins the narrowing: an event rbgo +// does not raise must not open the interpreter's gate, or every program would +// pay for a hook that can never fire. +func TestRecomputeTraceEventsMasksUnsupported(t *testing.T) { + vm := New(&bytes.Buffer{}) + vm.tracePoints = []*tracePoint{{events: evCCall | evRaise | evThreadBegin}} + vm.recomputeTraceEvents() + if vm.traceEvents != 0 { + t.Errorf("traceEvents = %#x for unsupported events only, want 0", vm.traceEvents) + } + vm.tracePoints = []*tracePoint{{events: evCCall | evLine}} + vm.recomputeTraceEvents() + if vm.traceEvents != evLine { + t.Errorf("traceEvents = %#x, want %#x", vm.traceEvents, evLine) + } +} + +// TestIseqOfTargetNative covers the native-method target: a Method record with +// no ISeq is the "specified target is not supported" ArgumentError, which is +// `RubyVM::InstructionSequence.of` answering nil for a C method. +func TestIseqOfTargetNative(t *testing.T) { + vm := New(&bytes.Buffer{}) + defer func() { + e, ok := recover().(RubyError) + if !ok || e.Class != "ArgumentError" { + t.Fatalf("want an ArgumentError, got %v", e) + } + }() + vm.iseqOfTarget(&BoundMethod{m: &Method{name: "puts", native: func(*VM, object.Value, []object.Value, *Proc) object.Value { return object.NilV }}}) +} + +// TestIseqOfTargetNilMethod covers the two nil-record guards, which a Method or +// UnboundMethod built by reflection could reach. +func TestIseqOfTargetNilMethod(t *testing.T) { + vm := New(&bytes.Buffer{}) + for _, target := range []object.Value{&BoundMethod{}, &UnboundMethod{}} { + func() { + defer func() { + e, ok := recover().(RubyError) + if !ok || e.Class != "ArgumentError" { + t.Errorf("iseqOfTarget(%T) = %v, want an ArgumentError", target, e) + } + }() + vm.iseqOfTarget(target) + }() + } +} + +// TestCollectISeqs covers the recursive walk, including its already-seen guard +// and its nil guard. +func TestCollectISeqs(t *testing.T) { + leaf := &bytecode.ISeq{Name: "leaf"} + root := &bytecode.ISeq{Name: "root", Children: []*bytecode.ISeq{leaf, leaf, nil}} + got := map[*bytecode.ISeq]bool{} + collectISeqs(root, got) + if len(got) != 2 || !got[root] || !got[leaf] { + t.Errorf("collectISeqs = %d entries, want root and leaf", len(got)) + } + collectISeqs(nil, got) + if len(got) != 2 { + t.Errorf("collectISeqs(nil) grew the set to %d", len(got)) + } +} + +// TestFrameTracePath covers both answers: an ISeq that names its file, and one +// that does not (the top-level program), which falls back to the frame label. +func TestFrameTracePath(t *testing.T) { + vm := New(&bytes.Buffer{}) + if got := vm.frameTracePath(&bytecode.ISeq{File: "app.rb"}, 0); got != "app.rb" { + t.Errorf("path = %q, want %q", got, "app.rb") + } + vm.SetScriptName("prog.rb") + if got := vm.frameTracePath(&bytecode.ISeq{}, 0); got != "prog.rb" { + t.Errorf("path = %q, want %q", got, "prog.rb") + } + // A frame index the stacks no longer cover (exec can outrun them; see the + // bounds note at the pc publication) reports the script rather than panicking. + if got := vm.frameTracePath(&bytecode.ISeq{}, 99); got != "prog.rb" { + t.Errorf("path for an out-of-range frame = %q, want %q", got, "prog.rb") + } +} + +// TestNewTraceFrameKinds pins the frame typing: block, class body, method, and +// the top-level/eval frame that raises neither event. +func TestNewTraceFrameKinds(t *testing.T) { + is := &bytecode.ISeq{} + cases := []struct { + name string + selfBlock *Proc + method string + classBody bool + entry traceEvents + exit traceEvents + }{ + {"block", &Proc{}, "", false, evBCall, evBReturn}, + {"lambda", &Proc{isLambda: true}, "", false, evBCall, evBReturn}, + {"class body", nil, "", true, evClass, evEnd}, + {"method", nil, "m", false, evCall, evReturn}, + {"top level", nil, "", false, 0, 0}, + } + for _, c := range cases { + f := newTraceFrame(is, 0, object.NilV, frameMethod{orig: "m", callee: "m"}, nil, nil, "", c.selfBlock, c.method, c.classBody) + if f.entry != c.entry || f.exit != c.exit { + t.Errorf("%s: entry/exit = %#x/%#x, want %#x/%#x", c.name, f.entry, f.exit, c.entry, c.exit) + } + if c.name == "class body" && (f.methodID != "" || f.calleeID != "") { + t.Errorf("class body kept method_id %q/%q", f.methodID, f.calleeID) + } + if c.name == "block" && !f.isProc { + t.Error("a non-lambda block must report isProc") + } + if c.name == "lambda" && f.isProc { + t.Error("a lambda must not report isProc") + } + } +} + +// TestTraceFrameKlassValue covers defined_class: a method frame names its owner +// and nothing else does. +func TestTraceFrameKlassValue(t *testing.T) { + c := newClass("K", nil) + if got := (&traceFrame{definee: c, entry: evCall}).klassValue(); got != c { + t.Errorf("klassValue for a method frame = %v, want K", got) + } + if got := (&traceFrame{definee: c, entry: evBCall}).klassValue(); got != nil { + t.Errorf("klassValue for a block frame = %v, want nil", got) + } + if got := (&traceFrame{entry: evCall}).klassValue(); got != nil { + t.Errorf("klassValue with no definee = %v, want nil", got) + } +} + +// TestTracePointDefinedClassNil covers the accessor's nil rendering, which a +// non-method event reaches. +func TestTracePointDefinedClassNil(t *testing.T) { + got := runSrc(t, ` +seen = 1 +TracePoint.new(:line) { |tp| seen = tp.defined_class }.enable { x = 1 } +p seen +`) + if got != "nil" { + t.Errorf("defined_class on a line event = %s, want nil", got) + } +} + +// TestTracePointHookOrder pins the two-list order — global hooks first, then +// the targeting ones, each most-recently-enabled first — which is the one +// property registration order alone cannot produce. +func TestTracePointHookOrder(t *testing.T) { + got := runSrc(t, ` +target = -> {} +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) + } +} + +// 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 := runSrc(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 := runSrc(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 := runSrc(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 := runSrc(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") + } +} + +// 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) + } +} diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 8242fdd6..bb86e1ee 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,15 @@ 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 + // 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: @@ -1589,6 +1642,34 @@ 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. + // 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 // this frame). A signal for some other target passes through. On a catch the @@ -1601,6 +1682,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 && !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 // pop never ran, and the deeper frames that unwound left theirs too. vm.frameNames = vm.frameNames[:frameNamesDepth-1] @@ -1618,6 +1706,21 @@ 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. + 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 +1764,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 +2200,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]) @@ -2315,6 +2440,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 && !tf.exitFired && vm.traceEvents&tf.exit != 0 { + tf.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 @@ -2346,6 +2478,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 && !tf.exitFired && vm.traceEvents&tf.exit != 0 { + tf.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); @@ -2550,6 +2688,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 +2716,21 @@ 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 { + tf.exitFired = true + 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 +2896,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 +2942,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) }