From 6b0c1c750f0cae913f0c1bf75952b764cecde21e Mon Sep 17 00:00:00 2001 From: siyul-park Date: Wed, 13 May 2026 07:49:25 +0900 Subject: [PATCH 1/2] feat: support debugger --- README.md | 7 +- README_kr.md | 7 +- docs/architecture.md | 8 +- docs/debugging.md | 103 ++++++++++++++++++ docs/instruction-set.md | 2 +- docs/jit-internals.md | 4 +- docs/pass-system.md | 2 +- docs/profile.md | 12 ++- interp/debugger.go | 224 ++++++++++++++++++++++++++++++++++++++++ interp/interp.go | 46 ++++++++- interp/interp_test.go | 213 ++++++++++++++++++++++++++++++++++++++ interp/threaded.go | 8 +- 12 files changed, 621 insertions(+), 15 deletions(-) create mode 100644 docs/debugging.md create mode 100644 interp/debugger.go diff --git a/README.md b/README.md index 429f35c..8fdaabe 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ vm := interp.New(prog, interp.WithStack(4096), // value stack slots (default: 1024) interp.WithHeap(512), // initial heap capacity (default: 128) interp.WithFrame(256), // max call depth (default: 128) - interp.WithThreshold(4096), // ticks before JIT (default: 4096) + interp.WithThreshold(4096), // ticks before JIT; 0 = first sample, negative disables JIT interp.WithTick(128), // sample/poll cadence (default: 128) interp.WithFuel(10_000), // instruction budget (default: unlimited) interp.WithHook(func(vm *interp.Interpreter) error { @@ -201,6 +201,11 @@ vm := interp.New(prog, `WithTick` controls profiling samples, context-cancellation polling, hook cadence, and fuel consumption. `WithFuel` accepts an expected instruction budget and rounds it up to the nearest tick interval internally; `WithFuel(0)` is unlimited. Hooks run synchronously on the `Run` goroutine and receive the live interpreter; avoid concurrent interpreter access and preserve VM invariants when mutating state. +For bytecode-level debugging, use `NewDebugger` with `WithDebugger`. The +debugger provides breakpoints plus `Step`, `Next`, and `Finish`; +`WithDebugger` configures instruction-accurate hooks and disables JIT. See +[`docs/debugging.md`](docs/debugging.md). + For profile snapshots, JIT counters, and REPL `.profile` output, see [`docs/profile.md`](docs/profile.md). diff --git a/README_kr.md b/README_kr.md index 570e459..621d8c7 100644 --- a/README_kr.md +++ b/README_kr.md @@ -189,7 +189,7 @@ vm := interp.New(prog, interp.WithStack(4096), // 값 스택 슬롯 수 (기본값: 1024) interp.WithHeap(512), // 초기 힙 용량 (기본값: 128) interp.WithFrame(256), // 최대 호출 깊이 (기본값: 128) - interp.WithThreshold(4096), // JIT 트리거 틱 수 (기본값: 4096) + interp.WithThreshold(4096), // JIT 트리거 틱 수; 0은 첫 샘플, 음수이면 JIT 비활성화 interp.WithTick(128), // 샘플/폴링 주기 (기본값: 128) interp.WithFuel(10_000), // 명령어 예산 (기본값: 무제한) interp.WithHook(func(vm *interp.Interpreter) error { @@ -201,6 +201,11 @@ vm := interp.New(prog, `WithTick`은 프로파일 샘플, context 취소 확인, hook 호출 주기, fuel 소비를 함께 제어합니다. `WithFuel`은 기대 명령어 예산을 받고 내부에서 가장 가까운 tick 간격으로 올림 변환합니다. `WithFuel(0)`은 무제한입니다. Hook은 `Run` 고루틴에서 동기적으로 실행되며 실행 중인 인터프리터를 받습니다. 인터프리터에 대한 동시 접근은 피하고, 상태를 변경할 때는 VM 불변식을 유지해야 합니다. +바이트코드 단위 디버깅은 `NewDebugger`를 `WithDebugger`와 함께 사용합니다. +디버거는 breakpoint와 `Step`, `Next`, `Finish`를 제공하며, `WithDebugger`는 +명령어 단위 hook을 설정하고 JIT를 비활성화합니다. 자세한 내용은 +[`docs/debugging.md`](docs/debugging.md)를 참고하세요. + --- ## 구현 현황 diff --git a/docs/architecture.md b/docs/architecture.md index 2126fa3..9187dfd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,6 +9,7 @@ Read this when a change crosses package boundaries or you need to know where sta | If you touch | Also read | | --- | --- | | `interp/` runtime state, frames, globals | [memory-model.md](memory-model.md), [value-representation.md](value-representation.md) | +| `interp/` debugger API or bytecode stepping | [debugging.md](debugging.md), [profile.md](profile.md) | | `prof/` or profile options | [profile.md](profile.md) | | `interp/threaded.go` or `interp/jit*.go` | [jit-internals.md](jit-internals.md), [instruction-set.md](instruction-set.md) | | `analysis/`, `transform/`, `optimize/`, `pass/` | [pass-system.md](pass-system.md) | @@ -201,7 +202,7 @@ Thin cobra entry point. The root command (no subcommand) launches the REPL with 4. interp.Run(ctx) ├─ main loop: code[f.ip](i) ├─ every 128 instructions: check ctx, consume fuel, call hook, prof.Add(addr, ip, opcode) - └─ when prof.Samples(addr) == threshold/tick: + └─ when JIT is enabled and prof.Samples(addr) reaches threshold rounded to tick cadence: jitCompiler.Compile(instrs[addr]) └─ heat-sorted two-pass over sampled basic blocks: ├─ pass 1: for each hot block, segment() loop → emit sampled eligible segments @@ -214,6 +215,11 @@ Thin cobra entry point. The root command (no subcommand) launches the REPL with └─ buffer.Free() → munmap ``` +`WithThreshold(0)` activates JIT on the first sample; negative thresholds +disable JIT compilation. Bytecode-level debugging uses +`NewDebugger` through `WithDebugger`, which configures instruction-accurate +hooks and disables JIT for exact instruction-boundary stops. + ## Focus Areas | Area | Direction | diff --git a/docs/debugging.md b/docs/debugging.md new file mode 100644 index 0000000..78ba4e6 --- /dev/null +++ b/docs/debugging.md @@ -0,0 +1,103 @@ +# Debugging + +Bytecode-level debugging for embedders. + +## When to Read This + +Read this when you: + +- build a debugger, tracer, or test harness around `interp.Run` +- need breakpoint, step, next, or finish control +- inspect the current bytecode location, call frames, or operand stack + +## Setup + +Use `NewDebugger` with `WithDebugger`: + +```go +dbg := interp.NewDebugger() +dbg.Break(0, 5) + +vm := interp.New(prog, interp.WithDebugger(dbg)) +defer vm.Close() + +for { + err := vm.Run(ctx) + if errors.Is(err, interp.ErrStopped) { + stop := dbg.Stop() + _ = stop + + dbg.Continue() + continue + } + if err != nil { + return err + } + break +} +``` + +`WithDebugger` installs the debugger hook, sets `WithTick(1)`, disables JIT with +`WithThreshold(-1)`, and asks the threaded compiler to preserve exact bytecode +instruction boundaries. + +## Controls + +| Method | Effect | +|---|---| +| `Continue()` | Run until a breakpoint, runtime error, context cancellation, fuel exhaustion, or normal exit | +| `Step()` | Execute one bytecode instruction, entering calls | +| `Next()` | Execute one bytecode instruction, stepping over calls | +| `Finish()` | Run until the current frame returns | + +All stops happen before the current instruction executes. `Run` returns +`ErrStopped`, and `Stop()` returns the function index, instruction pointer, and +breakpoint ID (`0` when the stop was caused by stepping). + +## Breakpoints + +Breakpoints are identified by function index and bytecode offset: + +```go +id := dbg.Break(0, 10) +dbg.Enable(id, false) +dbg.Enable(id, true) +dbg.Clear(id) +``` + +Use `BreakIf` for conditional breakpoints: + +```go +dbg.BreakIf(0, 10, func(vm *interp.Interpreter) bool { + return vm.Len() > 0 +}) +``` + +`Breakpoints()` returns a sorted snapshot by breakpoint ID. Each breakpoint +tracks `Hits`. + +## Inspection + +Use the stopped interpreter directly: + +| Method | Use | +|---|---| +| `Func()` | current function slot (`0` for top-level code) | +| `IP()` | current bytecode offset | +| `Opcode()` | opcode at the current bytecode offset | +| `FrameDepth()` | active frame count | +| `Frame(n)` | frame snapshot; `0` is current frame, `1` is caller | +| `Len()` / `Peek(n)` | operand stack inspection | +| `Local(n)` / `Global(n)` / `Const(n)` | value inspection | +| `Load(addr)` | resolve heap references | + +`Frame(n)` returns `(fn, ip, bp, err)` and does not expose mutable internal frame +state. + +## JIT and Precision + +Debugging is bytecode-level. Exact stepping requires instruction-boundary +execution, so `WithDebugger` disables JIT and uses `WithTick(1)`, which makes +threaded compilation preserve observable bytecode offsets. Normal non-debug +execution keeps threaded fusion optimizations such as NOP run collapsing and +`const.get` + `call` fusion. diff --git a/docs/instruction-set.md b/docs/instruction-set.md index a394008..ad47ebe 100644 --- a/docs/instruction-set.md +++ b/docs/instruction-set.md @@ -79,7 +79,7 @@ skips 5 bytes past the end of the 3-byte `BR` instruction. | Opcode | Widths | Stack | JIT | Description | |---|---|---|---|---| -| `NOP` | `{}` | `→` | ✅ | No-op. Threaded execution collapses consecutive NOP runs into a single dispatch step. JIT emits no native instruction. | +| `NOP` | `{}` | `→` | ✅ | No-op. Normal threaded execution collapses consecutive NOP runs into a single dispatch step; `WithTick(1)` preserves per-instruction hooks. JIT emits no native instruction. | | `DROP` | `{}` | `x →` | ✅ | Pop and discard the top value. | | `DUP` | `{}` | `x → x x` | ✅ | Duplicate the top value. | | `SWAP` | `{}` | `a b → b a` | ✅ | Swap the top two stack values. | diff --git a/docs/jit-internals.md b/docs/jit-internals.md index 027e6fd..e921d6f 100644 --- a/docs/jit-internals.md +++ b/docs/jit-internals.md @@ -59,7 +59,9 @@ instr.OPCODE: func(c *threadedCompiler) func(i *Interpreter) { - Reference counting: call `i.retain(addr)` when a ref enters the stack, `i.release(addr)` when consumed. - Do not catch errors inside closures — `panic(ErrX)` and let `interp.Run`'s `recover` handle it. -**Special case — NOP (threaded):** Each NOP scans forward to count all consecutive NOPs starting at its own position, then advances `c.ip` by 1. This means `n` consecutive NOPs produce `n` closures, but only the first is reached in execution — it jumps `n` bytes forward, bypassing the rest. The NOP-padding that `ConstantFoldingPass` inserts therefore takes one dispatch for the whole run. +**Special case — NOP (threaded):** In normal execution, each NOP scans forward to count all consecutive NOPs starting at its own position, then advances `c.ip` by 1. This means `n` consecutive NOPs produce `n` closures, but only the first is reached in execution — it jumps `n` bytes forward, bypassing the rest. The NOP-padding that `ConstantFoldingPass` inserts therefore takes one dispatch for the whole run. + +When `WithTick(1)` is configured, threaded compilation preserves exact bytecode instruction boundaries for hooks, profiling, and debugging. In that mode each NOP advances by one byte. ```go instr.NOP: func(c *threadedCompiler) func(i *Interpreter) { diff --git a/docs/pass-system.md b/docs/pass-system.md index 0008f36..f506005 100644 --- a/docs/pass-system.md +++ b/docs/pass-system.md @@ -136,7 +136,7 @@ Before: [I32_CONST 3][I32_CONST 4][I32_ADD] (11 bytes) After: [NOP][NOP][NOP][NOP][NOP][NOP][I32_CONST 7] (11 bytes) ``` -The threaded NOP handler fast-forwards past all consecutive NOPs at runtime, so the left padding takes one dispatch for the whole run. +The normal threaded NOP handler fast-forwards past all consecutive NOPs at runtime, so the left padding takes one dispatch for the whole run. `WithTick(1)` preserves per-instruction boundaries for exact hooks and debugging. Supported folds: - `I32_CONST × I32_CONST × op` for all arithmetic/bitwise/comparison ops diff --git a/docs/profile.md b/docs/profile.md index e9e1fa7..382ef0f 100644 --- a/docs/profile.md +++ b/docs/profile.md @@ -26,8 +26,9 @@ default tick is 128. Each sample records: The same tick cadence also drives context polling, fuel accounting, and hook callbacks. Lower ticks give denser profile data but add more polling and -sampling overhead. The REPL `.profile` command intentionally uses `WithTick(1)` -so small examples show exact per-instruction samples. +sampling overhead. `WithDebugger` configures instruction-accurate hooks for +bytecode debugging. The REPL `.profile` command also uses `WithTick(1)` so +small examples show exact per-instruction samples. ## Library API @@ -86,8 +87,11 @@ decisions. Treat them separately when tuning thresholds. ## Profile-Guided JIT -The JIT activates for a function when `Samples(fn) == WithThreshold / WithTick`. -The default is `4096 / 128 = 32` samples. +The JIT activates for a function when `Samples(fn)` reaches the configured +threshold rounded up to the tick cadence. The default is `4096 / 128 = 32` +samples. +`WithThreshold(0)` activates on the first sample. Negative thresholds disable +JIT compilation. At compilation time, the JIT uses profile data in two places: diff --git a/interp/debugger.go b/interp/debugger.go new file mode 100644 index 0000000..c11f342 --- /dev/null +++ b/interp/debugger.go @@ -0,0 +1,224 @@ +package interp + +import ( + "errors" + "sort" +) + +var ErrStopped = errors.New("debug stopped") + +type Stop struct { + Func int + IP int + Breakpoint int +} + +type Breakpoint struct { + ID int + Func int + IP int + Enabled bool + Hits uint64 + Cond func(*Interpreter) bool +} + +type Debugger struct { + breakpoints map[int]*Breakpoint + next int + mode debugMode + stop Stop + stoppedFlag bool + skip bool + skipFunc int + skipIP int + skipDepth int + pausedDepth int + depth int +} + +type debugMode int + +const ( + debugContinue debugMode = iota + debugStep + debugNext + debugFinish +) + +func WithDebugger(d *Debugger) func(*option) { + return func(o *option) { + if d == nil { + d = NewDebugger() + } + o.hook = d.Hook + o.tick = 1 + o.threshold = -1 + } +} + +func NewDebugger() *Debugger { + return &Debugger{ + breakpoints: make(map[int]*Breakpoint), + next: 1, + } +} + +func (d *Debugger) Hook(i *Interpreter) error { + fn, ip, depth := i.Func(), i.IP(), i.FrameDepth() + if d.skip && d.skipFunc == fn && d.skipIP == ip && d.skipDepth == depth { + d.skip = false + return nil + } + d.skip = false + + if bp := d.breakpoint(i, fn, ip); bp != nil { + bp.Hits++ + return d.stopped(fn, ip, depth, bp.ID) + } + + switch d.mode { + case debugStep: + return d.stopped(fn, ip, depth, 0) + case debugNext: + if depth <= d.depth { + return d.stopped(fn, ip, depth, 0) + } + case debugFinish: + if depth < d.depth { + return d.stopped(fn, ip, depth, 0) + } + } + return nil +} + +func (d *Debugger) Stop() Stop { + return d.stop +} + +func (d *Debugger) Continue() { + d.mode = debugContinue + d.resume() +} + +func (d *Debugger) Step() { + d.mode = debugStep + d.resume() +} + +func (d *Debugger) Next() { + d.mode = debugNext + d.depth = d.stopDepth() + d.resume() +} + +func (d *Debugger) Finish() { + d.mode = debugFinish + d.depth = d.stopDepth() + d.resume() +} + +func (d *Debugger) Break(fn, ip int) int { + return d.BreakIf(fn, ip, nil) +} + +func (d *Debugger) BreakIf(fn, ip int, cond func(*Interpreter) bool) int { + d.init() + id := d.next + d.next++ + d.breakpoints[id] = &Breakpoint{ + ID: id, + Func: fn, + IP: ip, + Enabled: true, + Cond: cond, + } + return id +} + +func (d *Debugger) Clear(id int) bool { + d.init() + if _, ok := d.breakpoints[id]; !ok { + return false + } + delete(d.breakpoints, id) + return true +} + +func (d *Debugger) Enable(id int, enabled bool) bool { + d.init() + bp := d.breakpoints[id] + if bp == nil { + return false + } + bp.Enabled = enabled + return true +} + +func (d *Debugger) Breakpoints() []Breakpoint { + d.init() + out := make([]Breakpoint, 0, len(d.breakpoints)) + for _, bp := range d.breakpoints { + out = append(out, *bp) + } + sort.Slice(out, func(i, j int) bool { + return out[i].ID < out[j].ID + }) + return out +} + +func (d *Debugger) breakpoint(i *Interpreter, fn, ip int) *Breakpoint { + d.init() + var hit *Breakpoint + for _, bp := range d.breakpoints { + if !bp.Enabled || bp.Func != fn || bp.IP != ip { + continue + } + if bp.Cond != nil && !bp.Cond(i) { + continue + } + if hit == nil || bp.ID < hit.ID { + hit = bp + } + } + return hit +} + +func (d *Debugger) stopped(fn, ip, depth, bp int) error { + d.stop = Stop{ + Func: fn, + IP: ip, + Breakpoint: bp, + } + d.stoppedFlag = true + d.pausedDepth = depth + d.mode = debugContinue + return ErrStopped +} + +func (d *Debugger) resume() { + if !d.stoppedFlag { + return + } + d.skip = true + d.skipFunc = d.stop.Func + d.skipIP = d.stop.IP + d.skipDepth = d.stopDepth() + d.stop = Stop{} + d.stoppedFlag = false +} + +func (d *Debugger) stopDepth() int { + if d.pausedDepth > 0 { + return d.pausedDepth + } + return 1 +} + +func (d *Debugger) init() { + if d.breakpoints == nil { + d.breakpoints = make(map[int]*Breakpoint) + } + if d.next == 0 { + d.next = 1 + } +} diff --git a/interp/interp.go b/interp/interp.go index 2ce2352..ce6accc 100644 --- a/interp/interp.go +++ b/interp/interp.go @@ -8,6 +8,7 @@ import ( "math" "github.com/siyul-park/minivm/asm" + "github.com/siyul-park/minivm/instr" "github.com/siyul-park/minivm/prof" "github.com/siyul-park/minivm/program" "github.com/siyul-park/minivm/types" @@ -31,7 +32,7 @@ type Interpreter struct { fp int sp int tick int - threshold uint64 + threshold int64 fuel int64 cutoff int } @@ -126,6 +127,9 @@ func New(prog *program.Program, opts ...func(*option)) *Interpreter { if opt.frame <= 0 { opt.frame = 1 } + if opt.tick <= 0 { + opt.tick = 1 + } p := opt.profile if p == nil { @@ -142,6 +146,13 @@ func New(prog *program.Program, opts ...func(*option)) *Interpreter { fuel = int64(ticks) } + var threshold int64 = int64(opt.threshold) + if threshold == 0 { + threshold = 1 + } else if threshold > 0 { + threshold = (threshold-1)/int64(opt.tick) + 1 + } + i := &Interpreter{ prof: p, hook: opt.hook, @@ -158,7 +169,7 @@ func New(prog *program.Program, opts ...func(*option)) *Interpreter { fp: 0, sp: 0, tick: opt.tick, - threshold: uint64(opt.threshold / opt.tick), + threshold: threshold, fuel: fuel, cutoff: opt.cutoff, } @@ -188,6 +199,7 @@ func New(prog *program.Program, opts ...func(*option)) *Interpreter { types: i.types, constants: i.constants, heap: i.heap, + precise: opt.tick == 1, } i.instrs[0] = prog.Code @@ -245,7 +257,7 @@ func (i *Interpreter) Run(ctx context.Context) (err error) { } i.prof.Add(f.addr, f.ip, i.instrs[f.addr][f.ip]) - if i.prof.Samples(f.addr) == i.threshold { + if i.threshold >= 0 && i.prof.Samples(f.addr) == uint64(i.threshold) { if err := i.jit(f.addr); err != nil { return err } @@ -264,6 +276,34 @@ func (i *Interpreter) Context() context.Context { return i.ctx } +func (i *Interpreter) Func() int { + return i.frame().addr +} + +func (i *Interpreter) IP() int { + return i.frame().ip +} + +func (i *Interpreter) FrameDepth() int { + return i.fp +} + +func (i *Interpreter) Opcode() (instr.Opcode, error) { + fn, ip := i.Func(), i.IP() + if fn < 0 || fn >= len(i.instrs) || ip < 0 || ip >= len(i.instrs[fn]) { + return 0, ErrSegmentationFault + } + return instr.Opcode(i.instrs[fn][ip]), nil +} + +func (i *Interpreter) Frame(n int) (fn, ip, bp int, err error) { + if n < 0 || n >= i.fp { + return 0, 0, 0, ErrFrameUnderflow + } + f := i.frames[i.fp-1-n] + return f.addr, f.ip, f.bp, nil +} + func (i *Interpreter) Const(idx int) (types.Boxed, error) { if idx < 0 || idx >= len(i.constants) { return 0, ErrSegmentationFault diff --git a/interp/interp_test.go b/interp/interp_test.go index df8b6a1..0b75965 100644 --- a/interp/interp_test.go +++ b/interp/interp_test.go @@ -2201,6 +2201,42 @@ func TestInterpreter_Run(t *testing.T) { require.Equal(t, []int{0, 1}, lens) }) + t.Run("normal tick keeps threaded nop fusion", func(t *testing.T) { + var ips []int + i := New(program.New([]instr.Instruction{ + instr.New(instr.NOP), + instr.New(instr.NOP), + instr.New(instr.NOP), + instr.New(instr.I32_CONST, 7), + }), WithTick(2), WithThreshold(-1), WithHook(func(i *Interpreter) error { + ips = append(ips, i.IP()) + return nil + })) + defer i.Close() + + err := i.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, []int{3}, ips) + }) + + t.Run("tick one preserves threaded nop boundaries", func(t *testing.T) { + var ips []int + i := New(program.New([]instr.Instruction{ + instr.New(instr.NOP), + instr.New(instr.NOP), + instr.New(instr.NOP), + instr.New(instr.I32_CONST, 7), + }), WithTick(1), WithThreshold(-1), WithHook(func(i *Interpreter) error { + ips = append(ips, i.IP()) + return nil + })) + defer i.Close() + + err := i.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, []int{0, 1, 2, 3}, ips) + }) + t.Run("profile records opcode samples", func(t *testing.T) { p := prof.New() i := New(program.New([]instr.Instruction{ @@ -2256,6 +2292,183 @@ func TestInterpreter_Run(t *testing.T) { require.ErrorIs(t, err, errHook) }) + t.Run("debugger breakpoint stops before instruction", func(t *testing.T) { + dbg := NewDebugger() + id := dbg.Break(0, 0) + i := New(program.New([]instr.Instruction{ + instr.New(instr.I32_CONST, 7), + }), WithDebugger(dbg)) + defer i.Close() + + err := i.Run(context.Background()) + require.ErrorIs(t, err, ErrStopped) + require.Equal(t, Stop{Func: 0, IP: 0, Breakpoint: id}, dbg.Stop()) + require.Equal(t, 0, i.Len()) + + dbg.Continue() + err = i.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, i.Len()) + require.Equal(t, uint64(1), dbg.Breakpoints()[0].Hits) + }) + + t.Run("debugger conditional breakpoint", func(t *testing.T) { + dbg := NewDebugger() + id := dbg.BreakIf(0, 5, func(i *Interpreter) bool { + return i.Len() == 1 + }) + i := New(program.New([]instr.Instruction{ + instr.New(instr.I32_CONST, 7), + instr.New(instr.DROP), + }), WithDebugger(dbg)) + defer i.Close() + + err := i.Run(context.Background()) + require.ErrorIs(t, err, ErrStopped) + require.Equal(t, id, dbg.Stop().Breakpoint) + require.Equal(t, 1, i.Len()) + }) + + t.Run("debugger breakpoint management", func(t *testing.T) { + var dbg Debugger + first := dbg.Break(0, 0) + second := dbg.Break(0, 1) + + require.True(t, dbg.Enable(first, false)) + require.False(t, dbg.Enable(99, false)) + require.True(t, dbg.Clear(second)) + require.False(t, dbg.Clear(second)) + + bps := dbg.Breakpoints() + require.Len(t, bps, 1) + require.Equal(t, first, bps[0].ID) + require.False(t, bps[0].Enabled) + }) + + t.Run("debugger helpers inspect current frame", func(t *testing.T) { + dbg := NewDebugger() + dbg.Break(0, 0) + i := New(program.New([]instr.Instruction{ + instr.New(instr.I32_CONST, 7), + }), WithDebugger(dbg)) + defer i.Close() + + err := i.Run(context.Background()) + require.ErrorIs(t, err, ErrStopped) + + require.Equal(t, 0, i.Func()) + require.Equal(t, 0, i.IP()) + require.Equal(t, 1, i.FrameDepth()) + op, err := i.Opcode() + require.NoError(t, err) + require.Equal(t, instr.I32_CONST, op) + fn, ip, bp, err := i.Frame(0) + require.NoError(t, err) + require.Equal(t, 0, fn) + require.Equal(t, 0, ip) + require.Equal(t, 0, bp) + _, _, _, err = i.Frame(1) + require.ErrorIs(t, err, ErrFrameUnderflow) + }) + + t.Run("debugger step next and finish around calls", func(t *testing.T) { + fn := types.NewFunctionBuilder(&types.FunctionType{ + Returns: []types.Type{types.TypeI32}, + }).Emit( + instr.New(instr.I32_CONST, 7), + instr.New(instr.RETURN), + ).Build() + prog := program.New([]instr.Instruction{ + instr.New(instr.CONST_GET, 0), + instr.New(instr.CALL), + instr.New(instr.DROP), + }, program.WithConstants(fn)) + + t.Run("step enters call", func(t *testing.T) { + dbg := NewDebugger() + dbg.Break(0, 3) + i := New(prog, WithDebugger(dbg)) + defer i.Close() + + require.ErrorIs(t, i.Run(context.Background()), ErrStopped) + dbg.Step() + require.ErrorIs(t, i.Run(context.Background()), ErrStopped) + require.Equal(t, 1, i.Func()) + require.Equal(t, 0, i.IP()) + require.Equal(t, 2, i.FrameDepth()) + fn, ip, _, err := i.Frame(0) + require.NoError(t, err) + require.Equal(t, 1, fn) + require.Equal(t, 0, ip) + fn, ip, _, err = i.Frame(1) + require.NoError(t, err) + require.Equal(t, 0, fn) + require.Equal(t, 4, ip) + }) + + t.Run("next steps over call", func(t *testing.T) { + dbg := NewDebugger() + dbg.Break(0, 3) + i := New(prog, WithDebugger(dbg)) + defer i.Close() + + require.ErrorIs(t, i.Run(context.Background()), ErrStopped) + dbg.Next() + require.ErrorIs(t, i.Run(context.Background()), ErrStopped) + require.Equal(t, 0, i.Func()) + require.Equal(t, 4, i.IP()) + require.Equal(t, 1, i.FrameDepth()) + require.Equal(t, 1, i.Len()) + }) + + t.Run("finish stops in caller", func(t *testing.T) { + dbg := NewDebugger() + dbg.Break(0, 3) + i := New(prog, WithDebugger(dbg)) + defer i.Close() + + require.ErrorIs(t, i.Run(context.Background()), ErrStopped) + dbg.Step() + require.ErrorIs(t, i.Run(context.Background()), ErrStopped) + dbg.Finish() + require.ErrorIs(t, i.Run(context.Background()), ErrStopped) + require.Equal(t, 0, i.Func()) + require.Equal(t, 4, i.IP()) + require.Equal(t, 1, i.FrameDepth()) + }) + }) + + t.Run("negative threshold disables jit", func(t *testing.T) { + p := prof.New() + i := New(program.New([]instr.Instruction{ + instr.New(instr.I32_CONST, 1), + instr.New(instr.I32_CONST, 2), + instr.New(instr.I32_ADD), + }), WithProfile(p), WithTick(1), WithThreshold(-1), WithCutoff(1)) + defer i.Close() + + err := i.Run(context.Background()) + require.NoError(t, err) + require.Zero(t, p.Snapshot().JIT.Attempts) + }) + + t.Run("threshold zero attempts jit on first sample", func(t *testing.T) { + if arch == nil { + t.Skip("jit is not available on this architecture") + } + p := prof.New() + i := New(program.New([]instr.Instruction{ + instr.New(instr.I32_CONST, 1), + instr.New(instr.I32_CONST, 2), + instr.New(instr.I32_ADD), + }), WithProfile(p), WithTick(1), WithThreshold(0), WithCutoff(1)) + defer i.Close() + + err := i.Run(context.Background()) + require.NoError(t, err) + require.Equal(t, uint64(1), p.Snapshot().JIT.Attempts) + }) + t.Run("fuel zero is unlimited", func(t *testing.T) { i := New(program.New([]instr.Instruction{ instr.New(instr.I32_CONST, 7), diff --git a/interp/threaded.go b/interp/threaded.go index 41bdaa0..8314c5b 100644 --- a/interp/threaded.go +++ b/interp/threaded.go @@ -13,14 +13,18 @@ type threadedCompiler struct { heap []types.Value code []byte ip int + precise bool } var threaded = [256]func(c *threadedCompiler) func(i *Interpreter){ instr.NOP: func(c *threadedCompiler) func(i *Interpreter) { skip := 0 - for c.ip+skip < len(c.code) && instr.Opcode(c.code[c.ip+skip]) == instr.NOP { + for !c.precise && c.ip+skip < len(c.code) && instr.Opcode(c.code[c.ip+skip]) == instr.NOP { skip++ } + if c.precise { + skip = 1 + } c.ip++ return func(i *Interpreter) { i.frames[i.fp-1].ip += skip @@ -393,7 +397,7 @@ var threaded = [256]func(c *threadedCompiler) func(i *Interpreter){ val := c.constants[idx] if val.Kind() == types.KindRef { addr := val.Ref() - if c.ip < len(c.code) { + if !c.precise && c.ip < len(c.code) { switch instr.Opcode(c.code[c.ip]) { case instr.CALL: switch fn := c.heap[addr].(type) { From 484d4ad84c2ffcbac7fe345546edd27892d2aec2 Mon Sep 17 00:00:00 2001 From: siyul-park Date: Wed, 13 May 2026 07:50:28 +0900 Subject: [PATCH 2/2] refactor: remove const --- interp/interp.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/interp/interp.go b/interp/interp.go index ce6accc..61b0ef1 100644 --- a/interp/interp.go +++ b/interp/interp.go @@ -139,9 +139,9 @@ func New(prog *program.Program, opts ...func(*option)) *Interpreter { var fuel int64 = -1 if opt.fuel > 0 { ticks := (opt.fuel-1)/uint64(opt.tick) + 1 - const max = uint64(1<<63 - 1) - if ticks > max { - fuel = int64(max) + m := uint64(1<<63 - 1) + if ticks > m { + fuel = int64(m) } fuel = int64(ticks) }