From d11f9c8d37224742b3a3e310ecd0af854de9277d Mon Sep 17 00:00:00 2001 From: allexanderbergmns Date: Tue, 25 Aug 2026 17:07:31 +0200 Subject: [PATCH] Debugger --- .github/workflows/ci.yml | 14 ++ README.MD | 59 ++++- compiler/codegen.v | 11 + examples/tests.vr | 24 ++ fuzz.v | 509 +++++++++++++++++++++++++++++++++++++++ linker/linker.v | 4 +- main.v | 180 ++++++++++++-- obj/obj.v | 58 ++++- vm/dbg.v | 178 ++++++++++++++ vm/native.v | 54 ++++- vm/types.v | 32 +++ vm/vm.v | 139 ++++++++++- 12 files changed, 1215 insertions(+), 47 deletions(-) create mode 100644 fuzz.v create mode 100644 vm/dbg.v diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 858a650..d0e0162 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,3 +77,17 @@ jobs: - name: Watch-mode and directory-test smoke run: | ./bin/vr test examples | grep -q 'passed' + + - name: Debugger smoke — breakpoint, locals, backtrace, continue + run: | + printf 'fn main() {\n\tlet x = 10\n\tlet y = x + 5\n\tprintln(y)\n}\n' > /tmp/dbg_smoke.vr + printf 'b 2\nc\nl\np x\nbt\nc\n' | ./bin/vr debug /tmp/dbg_smoke.vr | grep -q 'x = 0' + + - name: Profiler smoke — per-function instruction counts + run: | + printf 'fn fib(n) {\n\tif n < 2 {\n\t\treturn n\n\t}\n\treturn fib(n - 1) + fib(n - 2)\n}\nfn main() {\n\tprintln(fib(10))\n}\n' > /tmp/prof_smoke.vr + ./bin/vr profile /tmp/prof_smoke.vr | grep -q 'fib' + + - name: Fuzzer smoke — short run must finish clean + run: | + ./bin/vr fuzz --seed 1 --iters 25 --max-ops 50000 --save-dir /tmp/fuzz_repros | grep -q 'no bugs found' diff --git a/README.MD b/README.MD index ca7beea..a6b2538 100644 --- a/README.MD +++ b/README.MD @@ -30,8 +30,10 @@ vr compile [-o out.vobj] source -> object vr assemble [-o out.vobj] assembly -> object vr link [more.vobj ...] [-o out] objects -> executable (.vbin) vr run compile+link+run, or run a binary -vr debug run with an instruction trace -vr test run every test_* function +vr debug interactive debugger (breakpoints, step, locals) +vr profile run and report per-function instruction counts +vr fuzz [--seed N] [--iters N] fuzz the compiler and VM for crashes/hangs +vr test run every test_* function vr bench [iterations] benchmark main() vr make [target] [args...] run build.vrmm (target = main) vr make -f [target] [args...] run another build module @@ -57,6 +59,55 @@ vr info | loader | alloc | version | help ./bin/vr run -w f.vr # watch f.vr and rerun on every save ./bin/vr test examples # run every test_* fn in every .vr file under examples/ ./bin/vr lsp # language server for editors (diagnostics, go-to-def) +./bin/vr run --profile f.vr # hot-function report (instructions + calls) +``` + +### Debugging + +`vr debug` is an interactive debugger. Run to the first breakpoint (or stop at +entry), then inspect state: + +``` +vr debug f.vr --break 12 +== stopped at main (line 12, ip 105) — help: h +(vr-dbg) l # list locals with values +(vr-dbg) p x # print one local +(vr-dbg) bt # backtrace (call chain) +(vr-dbg) n # next: run to the next line (skips calls) +(vr-dbg) s # step: one instruction +(vr-dbg) f # finish: run until the current function returns +(vr-dbg) b 20 # set another breakpoint +(vr-dbg) c # continue +``` + +Breakpoints stop at the *start* of a source line, so locals hold the values +from before that line executes. `vr debug --trace f.vr` keeps the old +instruction-by-instruction trace. + +### Fuzzing + +`vr fuzz` generates random programs, compiles them, and runs each one twice +with an instruction budget — every case in its own subprocess so a crash in +the compiler or VM is isolated and reported instead of killing the run: + +``` +vr fuzz --seed 42 --iters 500 --save-dir fuzz-repros +fuzz: no bugs found +``` + +Findings (crashes, hangs via `--max-ops`, nondeterminism) are saved as .vr +repros; a nonzero exit reports bugs for CI. + +### Profiling + +`vr profile f.vr` (or `vr run --profile f.vr`) counts instructions and calls +per function and prints the hot-function table: + +``` +function calls instr % +fib 25 271 96.8% +main 0 9 3.2% +total 280 ``` Quick start: @@ -64,7 +115,7 @@ Quick start: ```bash ./bin/vr run examples/hello.vr # run a program ./bin/vr test examples/tests.vr # run the tests (one fails on purpose) -./bin/vr debug examples/hello.vr # watch every bytecode instruction +./bin/vr debug examples/hello.vr # interactive debugger (breakpoints, step, locals) # the assembler path ./bin/vr assemble examples/math.vasm -o math.vobj @@ -288,7 +339,7 @@ fn main() { literals; `has(m, k)`, `delete(m, k)`, `keys(m)`, `len(m)` all work - arrays: `[e1, e2, ...]`, indexing `a[i]` (read and write), `len(a)`, `push/insert/remove/pop/sort/reverse/clone/index_of/join`; array literals - may nest + may nest; `sort` is a stable merge sort — guaranteed O(n log n) - error handling: `try { ... } catch e { ... }` and `throw "message"` — the runtime unwinds to the nearest catch - bitwise operators: `& | ^ ~ << >>` diff --git a/compiler/codegen.v b/compiler/codegen.v index 9df1494..20254d0 100644 --- a/compiler/codegen.v +++ b/compiler/codegen.v @@ -35,6 +35,7 @@ mut: enum_vals map[string]int // 'Enum.variant' -> integer value consts map[string]i64 // constant name -> integer value lines []obj.LineInfo // code offset -> source line (debug info) + dbg_locals []obj.DbgLocal // per-function local name -> stack slot (debugger) local_cnt int argc int cur_fn string @@ -120,6 +121,11 @@ fn gen(prog Program) !obj.Obj { for l in imported.lines { g.lines << obj.LineInfo{ off: u32(code_off) + l.off, line: l.line } } + // merge debug locals: module-internal function names get the same + // prefix as their symbols so the debugger can resolve them + for l in imported.locals { + g.dbg_locals << obj.DbgLocal{ fn: prefix + l.fn, name: l.name, slot: l.slot } + } } for fd in prog.fns { g.captures = []string{} // top-level functions capture nothing @@ -131,6 +137,7 @@ fn gen(prog Program) !obj.Obj { code: g.code relocs: g.relocs lines: g.lines + locals: g.dbg_locals } } @@ -230,6 +237,10 @@ fn (mut g Gen) gen_fn(fd FnDecl) ! { } g.fixups.clear() g.labels.clear() + // snapshot the live locals as debug info for the debugger: name -> slot + for name, slot in g.locals { + g.dbg_locals << obj.DbgLocal{ fn: sym, name: name, slot: slot } + } g.cur_fn = '' } diff --git a/examples/tests.vr b/examples/tests.vr index 02c25b8..6b4ddbc 100644 --- a/examples/tests.vr +++ b/examples/tests.vr @@ -528,6 +528,30 @@ fn test_dynamic_map_keys() { assert cfg[which]["host"] == "localhost" } +fn test_sort_large() { + // merge sort must handle large arrays (O(n log n)) and stay sorted + let a = [] + let n = 2000 + let i = 0 + while i < n { + a = push(a, (n - i) % 97) + i = i + 1 + } + sort(a) + assert len(a) == n + let ok = 1 + let j = 1 + while j < n { + if a[j - 1] > a[j] { + ok = 0 + } + j = j + 1 + } + assert ok == 1 + assert a[0] == 0 + assert a[n - 1] == 96 +} + fn test_failing() { // this one is meant to fail — shows up in `vr test` output assert 1 == 2 diff --git a/fuzz.v b/fuzz.v new file mode 100644 index 0000000..106e3d1 --- /dev/null +++ b/fuzz.v @@ -0,0 +1,509 @@ +// fuzz.v — grammar-driven fuzzer for the compiler and VM. +// +// vr fuzz [--seed N] [--iters N] [--max-ops N] [--save-dir DIR] +// +// Each iteration generates a random VuurRaaf program from a small typed +// grammar (ints, floats, strings, booleans, arrays, structs/maps, closures, +// if/while, functions, try/catch), compiles it, and runs it twice with an +// instruction budget — all inside a fresh `vr fuzz --child` subprocess, so a +// crash in the generator, the compiler, or the VM is isolated to that one +// case and the run continues. +// +// Findings (each saved as a .vr repro in --save-dir, default ./fuzz-repros/): +// * crash — the child died (segfault / V panic) in any phase +// * hang — caught by the --max-ops instruction budget +// * nondeterminism — the same program produced different output across runs +module main + +import os +import compiler +import obj +import linker +import vm + +struct Rng { +mut: + state u64 +} + +fn (mut r Rng) next() u64 { + r.state ^= r.state << 13 + r.state ^= r.state >> 7 + r.state ^= r.state << 17 + return r.state +} + +fn (mut r Rng) intn(n int) int { + if n <= 0 { + return 0 + } + return int(r.next() % u64(n)) +} + +fn (mut r Rng) pick_str(items []string) string { + return items[r.intn(items.len)] +} + +// FuzzCtx carries the random generator plus the helper functions the +// generator may reference from generated expressions. +struct FuzzCtx { +mut: + rng Rng + helper []string // helper function names + helper_ar []int // argument counts, parallel to helper + vars []string // names declared in the function being generated +} + +const fuzz_int_ops = ['+', '-', '*', '/', '%'] +const fuzz_cmp_ops = ['==', '!=', '<', '<=', '>', '>='] +const fuzz_bool_ops = ['and', 'or'] +const fuzz_bools = ['true', 'false'] +const fuzz_names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'] + +fn toolchain_fuzz(args []string) ! { + if args.len > 0 && args[0] == '--child' { + // worker mode: generate + compile + run one seed, report via stderr + if args.len < 3 { + return error('fuzz --child needs [max_ops]') + } + seed := args[1].u64() + outdir := args[2] + mut max_ops := i64(200000) + if args.len > 3 { + max_ops = args[3].i64() + } + fuzz_child(seed, outdir, max_ops)! + return + } + mut seed := u64(1) + mut iters := 500 + mut max_ops := i64(200000) + mut save_dir := 'fuzz-repros' + mut i := 0 + for i < args.len { + a := args[i] + if a == '--seed' && i + 1 < args.len { + seed = args[i + 1].u64() + i += 2 + } else if a == '--iters' && i + 1 < args.len { + iters = args[i + 1].int() + i += 2 + } else if a == '--max-ops' && i + 1 < args.len { + max_ops = args[i + 1].i64() + i += 2 + } else if a == '--save-dir' && i + 1 < args.len { + save_dir = args[i + 1] + i += 2 + } else { + return error('unknown fuzz option "${a}" (supported: --seed N, --iters N, --max-ops N, --save-dir DIR)') + } + } + os.mkdir_all(save_dir) or { return error('cannot create save dir ${save_dir}: ${err}') } + exe := os.executable() + tmpdir := os.join_path(os.temp_dir(), 'vr_fuzz_${os.getpid()}') + os.mkdir_all(tmpdir) or { return error('cannot create ${tmpdir}: ${err}') } + defer { + os.rmdir_all(tmpdir) or {} + } + println('fuzz: seed=${seed} iters=${iters} max-ops=${max_ops} save-dir=${save_dir}') + println('fuzz: each case runs in its own subprocess; findings are saved as .vr repros') + mut bugs := 0 + mut compiled := 0 + mut ran := 0 + mut compile_fail := 0 + mut runtime_err := 0 + mut nondet := 0 + for it in 0..iters { + if it % 50 == 0 { + println('fuzz: iter ${it}/${iters} (compiled=${compiled} bugs=${bugs})') + } + cur_seed := seed + u64(it) + os.rmdir_all(os.join_path(tmpdir, 'case')) or {} + outdir := os.join_path(tmpdir, 'case') + os.mkdir_all(outdir) or {} + run1 := os.execute('"${exe}" fuzz --child ${cur_seed} "${outdir}" ${max_ops}') + ran++ + // classify crashes: anything that is not a clean exit-0 handshake + ok1 := run1.exit_code == 0 && (run1.output.contains('RUN_OK') || run1.output.contains('COMPILE_FAIL') || run1.output.contains('VM_ERR') || run1.output.contains('HANG')) + if !ok1 { + phase := crash_phase(outdir) + eprintln('fuzz: CRASH (exit ${run1.exit_code}, phase=${phase}) at iter ${it} seed ${cur_seed}') + bugs++ + save_repro(save_dir, cur_seed, it, outdir, phase) + continue + } + if run1.output.contains('COMPILE_FAIL') { + compile_fail++ + continue + } + if run1.output.contains('VM_ERR') || run1.output.contains('HANG') { + runtime_err++ + continue + } + compiled++ + // determinism: run the identical case again and compare output + run2 := os.execute('"${exe}" fuzz --child ${cur_seed} "${outdir}" ${max_ops}') + ran++ + if run2.exit_code != 0 || run2.output != run1.output { + eprintln('fuzz: NONDETERMINISM at iter ${it} seed ${cur_seed}') + println('run1 (exit ${run1.exit_code}): ${run1.output}') + println('run2 (exit ${run2.exit_code}): ${run2.output}') + nondet++ + bugs++ + save_repro(save_dir, cur_seed, it, outdir, 'nondeterminism') + } + } + println('') + println('fuzz: done — ${iters} iterations, ${compiled} compiled, ${ran} runs') + println('fuzz: compile-fail=${compile_fail} runtime-errors=${runtime_err} bugs=${bugs} nondeterminism=${nondet}') + if bugs > 0 { + println('fuzz: BUGS FOUND — repros saved in ${save_dir}/') + exit(1) + } + println('fuzz: no bugs found') +} + +// crash_phase inspects the worker's output dir to say where the crash happened. +fn crash_phase(outdir string) string { + case_file := os.join_path(outdir, 'case.vr') + if !os.exists(case_file) { + return 'generator' + } + if !os.exists(os.join_path(outdir, 'compiled.ok')) { + return 'compiler' + } + return 'vm' +} + +// save_repro copies the failing case's source into the save dir. +fn save_repro(dir string, seed u64, iter int, outdir string, what string) { + src := os.read_file(os.join_path(outdir, 'case.vr')) or { + // generator crash: no source was produced; record the seed + note := os.join_path(dir, 'repro_seed${seed}_iter${iter}.seed') + os.write_file(note, 'seed=${seed}\nphase=${what}\n') or {} + eprintln('fuzz: saved seed note -> ${note}') + return + } + repro_path := os.join_path(dir, 'repro_seed${seed}_iter${iter}.vr') + os.write_file(repro_path, '// repro: ${what}\n// seed=${seed}\n\n${src}') or {} + eprintln('fuzz: saved repro -> ${repro_path}') +} + +// fuzz_child is the worker: generate one program, save it, compile it, and run +// it with an instruction budget. Contract with the parent: +// stderr 'COMPILE_FAIL' — program did not compile (expected) +// stderr 'VM_ERR' — program ran but raised a runtime error (expected) +// stderr 'HANG' — the max-ops budget fired (expected) +// stderr 'RUN_OK' — program finished; stdout is its output +// Any other termination (nonzero exit, panic, segfault) is a finding. +fn fuzz_child(seed u64, outdir string, max_ops i64) ! { + src := gen_program(seed) + os.write_file(os.join_path(outdir, 'case.vr'), src) or { + return error('cannot write case: ${err}') + } + o := compiler.compile(src) or { + eprintln('COMPILE_FAIL') + return + } + os.write_file(os.join_path(outdir, 'compiled.ok'), 'ok') or {} + tmp_obj := os.join_path(outdir, 'case.vobj') + tmp_bin := os.join_path(outdir, 'case.vbin') + obj.write(tmp_obj, o) or { return error('obj write: ${err}') } + linker.link([tmp_obj], tmp_bin) or { + eprintln('LINK_FAIL: ${err}') + return + } + bin := obj.read_bin(tmp_bin) or { return error('bin read: ${err}') } + vm.run_opts(bin, 'main', vm.RunOpts{ max_ops: max_ops }) or { + msg := err.msg() + if msg.contains('max ops exceeded') { + eprintln('HANG') + } else { + eprintln('VM_ERR') + } + return + } + eprintln('RUN_OK') +} + +// --------------------------------------------------------------------------- +// program generation + +fn gen_program(seed u64) string { + mut ctx := FuzzCtx{ rng: Rng{ state: if seed == 0 { u64(1) } else { seed } } } + mut out := '' + // random helper functions (0..3) + n_helpers := ctx.rng.intn(4) + for h in 0..n_helpers { + fname := 'h${h}' + ctx.helper << fname + ctx.helper_ar << ctx.rng.intn(3) + out += gen_fn(mut ctx, fname, 2) + } + out += 'fn main() {\n' + out += gen_body(mut ctx, 3) + out += '\tprintln("done")\n}\n' + return out +} + +// gen_fn generates a function declaration. depth bounds expression nesting. +fn gen_fn(mut ctx FuzzCtx, fname string, depth int) string { + params := ctx.rng.intn(4) + mut out := 'fn ${fname}(' + mut param_names := []string{} + for i in 0..params { + p := fuzz_names[ctx.rng.intn(fuzz_names.len)] + param_names << p + if i > 0 { + out += ', ' + } + out += p + } + out += ') {\n' + ctx.vars = []string{} + for p in param_names { + ctx.vars << p + out += '\tlet ${p} = ${p}\n' // re-bind so params are named locals + } + e := gen_expr(mut ctx, depth, 0) + out += '\tlet r = ${e}\n' + out += '\treturn r\n}\n' + return out +} + +// gen_body generates statements for a function body. +fn gen_body(mut ctx FuzzCtx, depth int) string { + mut out := '' + n := ctx.rng.intn(6) + for i in 0..n { + out += gen_stmt(mut ctx, depth) + } + return out +} + +fn gen_stmt(mut ctx FuzzCtx, depth int) string { + // control-flow statements recurse into gen_body with depth-1 and are only + // allowed above depth 0, so nested if/while/try cannot grow unboundedly + // below depth 1 only leaf statements (let/assign/expr/push) are allowed, + // so nested control flow cannot grow unboundedly + mut t := ctx.rng.intn(7) + if depth <= 0 { + t = match ctx.rng.intn(4) { + 0 { 0 } + 1 { 1 } + 2 { 4 } + else { 5 } + } + } + match t { + 0 { // let with expression (declares a fresh name) + v := fuzz_names[ctx.rng.intn(fuzz_names.len)] + ctx.vars << v + e := gen_expr(mut ctx, depth, 0) + return '\tlet ${v} = ${e}\n' + } + 1 { // assignment to an existing var (or a let when none exist) + if ctx.vars.len > 0 { + v := ctx.vars[ctx.rng.intn(ctx.vars.len)] + e := gen_expr(mut ctx, depth, 0) + return '\t${v} = ${e}\n' + } + v := fuzz_names[ctx.rng.intn(fuzz_names.len)] + ctx.vars << v + e := gen_expr(mut ctx, depth, 0) + return '\tlet ${v} = ${e}\n' + } + 2 { // if/else + cond := gen_expr(mut ctx, depth, 0) + b1 := gen_body(mut ctx, depth - 1) + b2 := gen_body(mut ctx, depth - 1) + return '\tif ${cond} {\n${b1}' + '\t} else {\n${b2}' + '\t}\n' + } + 3 { // while with random condition (sometimes true: exercises max-ops) + cond := if ctx.rng.intn(4) == 0 { 'true' } else { gen_expr(mut ctx, depth, 0) } + return '\twhile ${cond} {\n${gen_body(mut ctx, depth - 1)}' + '\t}\n' + } + 4 { // expression statement + e := gen_expr(mut ctx, depth, 0) + return '\t${e}\n' + } + 5 { // push to array + if ctx.vars.len == 0 { + return '\tlet a = []\n' + } + v := ctx.vars[ctx.rng.intn(ctx.vars.len)] + e := gen_expr(mut ctx, depth, 0) + return '\t${v} = push(${v}, ${e})\n' + } + else { // try/catch + return '\ttry {\n${gen_body(mut ctx, depth - 1)}' + '\t} catch e {\n\t\tlet msg = e\n\t}\n' + } + } + return '' +} + +// gen_expr generates a random expression; depth bounds nesting. +fn gen_expr(mut ctx FuzzCtx, depth int, ty int) string { + if depth <= 0 || ctx.rng.intn(3) == 0 { + return gen_atom(mut ctx, ty) + } + // pick a construct compatible with the requested type (0 = any) + mut choices := [0, 4, 6, 11] // variable, literal, len, call + if ty == 0 || ty == 1 { + choices << 0 // arithmetic + choices << 5 // array index + choices << 9 // struct field + } + if ty == 0 || ty == 3 { + choices << 1 // comparison -> bool + choices << 2 // boolean ops + choices << 3 // not + } + if ty == 0 || ty == 4 { + choices << 7 // string concat + } + if ty == 0 || ty == 5 { + choices << 8 // struct literal + } + t := choices[ctx.rng.intn(choices.len)] + match t { + 0 { // arithmetic -> int + a := gen_expr(mut ctx, depth - 1, 1) + op := ctx.rng.pick_str(fuzz_int_ops) + b := gen_expr(mut ctx, depth - 1, 1) + return '(${a} ${op} ${b})' + } + 1 { // comparison -> bool + a := gen_expr(mut ctx, depth - 1, 1) + op := ctx.rng.pick_str(fuzz_cmp_ops) + b := gen_expr(mut ctx, depth - 1, 1) + return '(${a} ${op} ${b})' + } + 2 { // boolean ops -> bool + a := gen_expr(mut ctx, depth - 1, 3) + op := ctx.rng.pick_str(fuzz_bool_ops) + b := gen_expr(mut ctx, depth - 1, 3) + return '(${a} ${op} ${b})' + } + 3 { // not -> bool + e := gen_expr(mut ctx, depth - 1, 3) + return 'not (${e})' + } + 4 { // array literal of ints + n := ctx.rng.intn(4) + mut parts := []string{} + for i in 0..n { + parts << gen_expr(mut ctx, depth - 1, 1) + } + return '[${parts.join(', ')}]' + } + 5 { // array index -> int + if ctx.vars.len == 0 { + n := ctx.rng.intn(21) - 10 + return '${n}' + } + v := ctx.vars[ctx.rng.intn(ctx.vars.len)] + ix := ctx.rng.intn(5) + return '${v}[${ix}]' + } + 6 { // len() -> int + if ctx.vars.len == 0 { + n := ctx.rng.intn(21) - 10 + return '${n}' + } + v := ctx.vars[ctx.rng.intn(ctx.vars.len)] + return 'len(${v})' + } + 7 { // string concat -> string + a := gen_expr(mut ctx, depth - 1, 4) + b := gen_expr(mut ctx, depth - 1, 4) + return '(${a} + ${b})' + } + 8 { // struct literal + mut parts := []string{} + n := 1 + ctx.rng.intn(3) + for i in 0..n { + e := gen_expr(mut ctx, depth - 1, 1) + parts << '"f${i}": ${e}' + } + return '{${parts.join(', ')}}' + } + 9 { // struct field access -> int + if ctx.vars.len == 0 { + n := ctx.rng.intn(21) - 10 + return '${n}' + } + v := ctx.vars[ctx.rng.intn(ctx.vars.len)] + return '${v}.f${ctx.rng.intn(3)}' + } + 11 { // call a helper + if ctx.helper.len > 0 { + h := ctx.rng.intn(ctx.helper.len) + np := ctx.helper_ar[h] + mut args := []string{} + for i in 0..np { + args << gen_expr(mut ctx, depth - 1, 1) + } + hname := ctx.helper[h] + return '${hname}(${args.join(', ')})' + } + return gen_atom(mut ctx, ty) + } + else { + return gen_atom(mut ctx, ty) + } + } +} + +fn gen_params_decl(mut ctx FuzzCtx) string { + n := ctx.rng.intn(3) + mut parts := []string{} + for i in 0..n { + parts << fuzz_names[ctx.rng.intn(fuzz_names.len)] + } + return parts.join(', ') +} + +// gen_atom generates a leaf expression of the requested type (0 = any). +fn gen_atom(mut ctx FuzzCtx, ty int) string { + match ty { + 1 { // int + n := ctx.rng.intn(21) - 10 + return '${n}' + } + 2 { // float + n := ctx.rng.intn(101) + return '${f64(n) / 10.0}' + } + 3 { // bool + return ctx.rng.pick_str(fuzz_bools) + } + 4 { // string + n := ctx.rng.intn(100) + return '"s${n}"' + } + else { // any: mostly ints, sometimes a variable or a string + t := ctx.rng.intn(10) + if t < 5 { + n := ctx.rng.intn(21) - 10 + return '${n}' + } + if t < 7 { + n := ctx.rng.intn(101) + return '${f64(n) / 10.0}' + } + if t < 8 { + return ctx.rng.pick_str(fuzz_bools) + } + if t < 9 { + n := ctx.rng.intn(100) + return '"s${n}"' + } + if ctx.vars.len > 0 { + return ctx.vars[ctx.rng.intn(ctx.vars.len)] + } + n := ctx.rng.intn(21) - 10 + return '${n}' + } + } +} diff --git a/linker/linker.v b/linker/linker.v index 07eccf9..3fd39c6 100644 --- a/linker/linker.v +++ b/linker/linker.v @@ -16,6 +16,7 @@ pub fn link(paths []string, out string) ! { mut symbols := map[string]int{} mut relocs := []obj.Reloc{} mut lines := []obj.LineInfo{} + mut locals := []obj.DbgLocal{} for p in paths { o := obj.read(p)! base := code.len @@ -32,6 +33,7 @@ pub fn link(paths []string, out string) ! { for l in o.lines { lines << obj.LineInfo{ off: l.off + u32(base), line: l.line } } + locals << o.locals } // resolve relocations for r in relocs { @@ -51,7 +53,7 @@ pub fn link(paths []string, out string) ! { for name, entry in symbols { fns << obj.BinFn{ name: name, entry: entry } } - obj.write_bin(out, obj.Bin{ fns: fns, strings: strings, code: code, lines: lines })! + obj.write_bin(out, obj.Bin{ fns: fns, strings: strings, code: code, lines: lines, locals: locals })! } fn intern_str(mut table []string, s string) int { diff --git a/main.v b/main.v index 86bf39d..41ec5e8 100644 --- a/main.v +++ b/main.v @@ -61,9 +61,15 @@ fn main() { 'run', 'r' { toolchain_run(rest) or { die('run', err) } } - 'debug', 'd' { - toolchain_debug(rest) or { die('debug', err) } - } + 'debug', 'd' { + toolchain_debug(rest) or { die('debug', err) } + } + 'profile', 'p' { + toolchain_profile(rest) or { die('profile', err) } + } + 'fuzz' { + toolchain_fuzz(rest) or { die('fuzz', err) } + } 'test', 't' { toolchain_test(rest) or { die('test', err) } } @@ -189,8 +195,10 @@ fn toolchain_help() { println(' assemble [-o out.vobj] assemble raw bytecode to an object file') println(' link [more.vobj ...] [-o out] link objects into an executable') println(' run compile, link and run (or run a binary)') - println(' debug run with an instruction trace') - println(' test run every test_* function') + println(' debug interactive debugger (breakpoints, step, locals)') + println(' profile run and report per-function instruction counts') + println(' fuzz [--seed N] [--iters N] fuzz the compiler and VM for crashes/hangs') + println(' test run every test_* function') println(' bench [iterations] benchmark main()') println(' repl interactive session') println(' lsp language server (JSON-RPC over stdio)') @@ -362,16 +370,28 @@ fn toolchain_link(args []string) ! { fn toolchain_run(args []string) ! { if args.len == 0 { - return error('usage: vr run [program-args...] (add -w to watch for changes)') + return error('usage: vr run [program-args...] (add -w to watch, --profile to profile, --max-ops N to cap instructions)') } mut watch := false + mut profile := false + mut max_ops := i64(0) mut rest := args.clone() - if rest[0] == '-w' || rest[0] == '--watch' { - watch = true - rest = rest[1..] + for rest.len > 0 && (rest[0].starts_with('-') && rest[0] != '-') { + if rest[0] == '-w' || rest[0] == '--watch' { + watch = true + rest = rest[1..] + } else if rest[0] == '--profile' { + profile = true + rest = rest[1..] + } else if rest[0] == '--max-ops' && rest.len > 1 { + max_ops = rest[1].i64() + rest = rest[2..] + } else { + return error('unknown flag "${rest[0]}" (supported: -w, --profile, --max-ops N)') + } } if rest.len == 0 { - return error('usage: vr run [program-args...] (add -w to watch for changes)') + return error('usage: vr run [program-args...] (add -w to watch, --profile to profile, --max-ops N to cap instructions)') } f := rest[0] prog_args := rest[1..] @@ -381,16 +401,76 @@ fn toolchain_run(args []string) ! { } if f.ends_with('.vbin') { bin := obj.read_bin(f)! - vm.run_with_args(bin, 'main', false, prog_args)! + if profile { + print_profile(vm.run_profiled(bin, 'main', prog_args)!, f) + return + } + vm.run_opts(bin, 'main', vm.RunOpts{ args: prog_args, max_ops: max_ops })! return } if f.ends_with('.vr') { - run_src_with_args(f, 'main', false, prog_args)! + if profile { + run_src_profiled(f, prog_args)! + return + } + run_src_with_args(f, 'main', false, prog_args, max_ops)! return } return error('unsupported file type: ${f} (expected .vr or .vbin)') } +// print_profile renders the profiled run's report as a table. +fn print_profile(rep vm.ProfileReport, f string) { + println('profile: ${f}') + println('${pad_right('function', 24)}${pad_left('calls', 8)}${pad_left('instr', 12)}${pad_left('%', 7)}') + for r in rep.rows { + if r.instr == 0 && r.calls == 0 { + continue + } + pct := if rep.total > 0 { 100.0 * f64(r.instr) / f64(rep.total) } else { 0.0 } + println('${pad_right(r.name, 24)}${pad_left(r.calls.str(), 8)}${pad_left(r.instr.str(), 12)}${pad_left(pct_fmt(pct) + '%', 7)}') + } + println('${pad_right('total', 24)}${pad_left(rep.total.str(), 12)}') +} + +// pad_left pads s with spaces on the left to reach width w. +fn pad_left(s string, w int) string { + mut out := s + for out.len < w { + out = ' ' + out + } + return out +} + +// pad_right pads s with spaces on the right to reach width w. +fn pad_right(s string, w int) string { + mut out := s + for out.len < w { + out += ' ' + } + return out +} + +// pct_fmt renders a percentage with one decimal. +fn pct_fmt(p f64) string { + return '${p:.1f}' +} + +// run_src_profiled compiles+links a source file and runs it with profiling. +fn run_src_profiled(src string, prog_args []string) ! { + tmp_obj := os.join_path(os.temp_dir(), 'vr_${os.getpid()}.vobj') + tmp_bin := os.join_path(os.temp_dir(), 'vr_${os.getpid()}.vbin') + defer { + os.rm(tmp_obj) or {} + os.rm(tmp_bin) or {} + } + o := compiler.compile_file(src)! + obj.write(tmp_obj, o)! + linker.link([tmp_obj], tmp_bin)! + bin := obj.read_bin(tmp_bin)! + print_profile(vm.run_profiled(bin, 'main', prog_args)!, src) +} + // run_watch recompiles and reruns the program whenever the source file (or // anything it imports) changes — the classic develop-run-edit loop. fn run_watch(f string, prog_args []string) ! { @@ -403,7 +483,7 @@ fn run_watch(f string, prog_args []string) ! { // clear the screen between runs for a clean diff of output print('\x1b[2J\x1b[H') println('== ${os.file_name(f)} — ${time.now().custom_format('HH:mm:ss')} ==') - run_src_with_args(f, 'main', false, prog_args) or { + run_src_with_args(f, 'main', false, prog_args, 0) or { eprintln('${err.msg()}') } for { @@ -417,24 +497,80 @@ fn run_watch(f string, prog_args []string) ! { } } +// toolchain_debug starts the interactive debugger: run to the first +// --break , then accept commands (continue/step/next/finish/print/...). +// With no breakpoints it stops at program entry so breakpoints can be set +// before anything runs. --trace keeps the old full instruction trace. fn toolchain_debug(args []string) ! { - if args.len == 0 { - return error('usage: vr debug ') + mut f := '' + mut bps := []int{} + mut trace := false + mut i := 0 + for i < args.len { + a := args[i] + if a == '--break' && i + 1 < args.len { + bps << args[i + 1].int() + i += 2 + } else if a == '--trace' { + trace = true + i++ + } else if f == '' { + f = a + i++ + } else { + return error('unexpected argument "${a}" (usage: vr debug [--break N]... [--trace])') + } + } + if f == '' { + return error('usage: vr debug [--break N]... [--trace]') } - f := args[0] - println('debug: tracing execution of ${f}') if f.ends_with('.vbin') { bin := obj.read_bin(f)! - vm.run(bin, 'main', true)! + vm.run_opts(bin, 'main', vm.RunOpts{ debug: true, breakpoints: bps, trace: trace })! return } if f.ends_with('.vr') { - run_src(f, 'main', true)! + run_src_debug(f, bps, trace)! return } return error('unsupported file type: ${f} (expected .vr or .vbin)') } +// toolchain_profile is `vr profile [args...]` — run with per-function +// instruction/call counting and print the hot-function report. +fn toolchain_profile(args []string) ! { + if args.len == 0 { + return error('usage: vr profile [program-args...]') + } + f := args[0] + prog_args := args[1..] + if f.ends_with('.vbin') { + bin := obj.read_bin(f)! + print_profile(vm.run_profiled(bin, 'main', prog_args)!, f) + return + } + if f.ends_with('.vr') { + run_src_profiled(f, prog_args)! + return + } + return error('unsupported file type: ${f} (expected .vr or .vbin)') +} + +// run_src_debug compiles+links a source file and runs it under the debugger. +fn run_src_debug(src string, bps []int, trace bool) ! { + tmp_obj := os.join_path(os.temp_dir(), 'vr_${os.getpid()}.vobj') + tmp_bin := os.join_path(os.temp_dir(), 'vr_${os.getpid()}.vbin') + defer { + os.rm(tmp_obj) or {} + os.rm(tmp_bin) or {} + } + o := compiler.compile_file(src)! + obj.write(tmp_obj, o)! + linker.link([tmp_obj], tmp_bin)! + bin := obj.read_bin(tmp_bin)! + vm.run_opts(bin, 'main', vm.RunOpts{ debug: true, breakpoints: bps, trace: trace })! +} + fn toolchain_test(args []string) ! { if args.len == 0 { return error('usage: vr test ') @@ -573,10 +709,10 @@ fn toolchain_bench(args []string) ! { } fn run_src(src string, entry string, trace bool) ! { - run_src_with_args(src, entry, trace, []string{})! + run_src_with_args(src, entry, trace, []string{}, 0)! } -fn run_src_with_args(src string, entry string, trace bool, args []string) ! { +fn run_src_with_args(src string, entry string, trace bool, args []string, max_ops i64) ! { tmp_obj := os.join_path(os.temp_dir(), 'vr_${os.getpid()}.vobj') tmp_bin := os.join_path(os.temp_dir(), 'vr_${os.getpid()}.vbin') defer { @@ -587,7 +723,7 @@ fn run_src_with_args(src string, entry string, trace bool, args []string) ! { obj.write(tmp_obj, o)! linker.link([tmp_obj], tmp_bin)! bin := obj.read_bin(tmp_bin)! - vm.run_with_args(bin, entry, trace, args)! + vm.run_opts(bin, entry, vm.RunOpts{ trace: trace, args: args, max_ops: max_ops })! } // --------------------------------------------------------------------------- diff --git a/obj/obj.v b/obj/obj.v index 0fa6a53..0c5143e 100644 --- a/obj/obj.v +++ b/obj/obj.v @@ -38,6 +38,7 @@ pub mut: code []u8 relocs []Reloc lines []LineInfo + locals []DbgLocal } pub struct BinFn { @@ -54,12 +55,23 @@ pub mut: line int } +// DbgLocal is one named local variable of a function (debug info for the +// interactive debugger): the VM can resolve `print ` to the value at +// stack slot bp+slot while stopped inside `fn`. +pub struct DbgLocal { +pub mut: + fn string + name string + slot int +} + pub struct Bin { pub mut: fns []BinFn strings []string code []u8 lines []LineInfo + locals []DbgLocal } // --------------------------------------------------------------------------- @@ -143,10 +155,14 @@ fn (mut r Reader) read_str() !string { // --------------------------------------------------------------------------- // VROBJ +// format_version is 2 since v0.2: a debug-locals section was appended after +// the line table. Version 1 files (no locals) still read fine. +const format_version = u8(2) + pub fn write(path string, o Obj) ! { mut b := []u8{} b << magic.bytes() - b << u8(1) // format version + b << format_version b << encode_u32(u32(o.symbols.len)) for s in o.symbols { b << encode_u32(u32(s.name.len)) @@ -172,6 +188,14 @@ pub fn write(path string, o Obj) ! { b << encode_u32(l.off) b << encode_i64(i64(l.line)) } + b << encode_u32(u32(o.locals.len)) + for l in o.locals { + b << encode_u32(u32(l.fn.len)) + b << l.fn.bytes() + b << encode_u32(u32(l.name.len)) + b << l.name.bytes() + b << encode_i64(i64(l.slot)) + } os.write_bytes(path, b)! } @@ -181,7 +205,7 @@ pub fn read(path string) !Obj { return error('not a VROBJ file: ${path}') } mut r := Reader{ b: b, pos: magic.len } - _ := r.u8_()! // version + ver := r.u8_()! // format version mut o := Obj{} nsym := int(r.u32_()!) for _ in 0..nsym { @@ -212,6 +236,15 @@ pub fn read(path string) !Obj { line := int(r.i64_()!) o.lines << LineInfo{ off: off, line: line } } + if ver >= 2 { + nlocals := int(r.u32_()!) + for _ in 0..nlocals { + fn_name := r.read_str()! + name := r.read_str()! + slot := int(r.i64_()!) + o.locals << DbgLocal{ fn: fn_name, name: name, slot: slot } + } + } return o } @@ -221,7 +254,7 @@ pub fn read(path string) !Obj { pub fn write_bin(path string, bin Bin) ! { mut b := []u8{} b << bin_magic.bytes() - b << u8(1) // format version + b << format_version b << encode_u32(u32(bin.fns.len)) for f in bin.fns { b << encode_u32(u32(f.name.len)) @@ -240,6 +273,14 @@ pub fn write_bin(path string, bin Bin) ! { b << encode_u32(l.off) b << encode_i64(i64(l.line)) } + b << encode_u32(u32(bin.locals.len)) + for l in bin.locals { + b << encode_u32(u32(l.fn.len)) + b << l.fn.bytes() + b << encode_u32(u32(l.name.len)) + b << l.name.bytes() + b << encode_i64(i64(l.slot)) + } os.write_bytes(path, b)! } @@ -249,7 +290,7 @@ pub fn read_bin(path string) !Bin { return error('not a VRBIN file: ${path}') } mut r := Reader{ b: b, pos: bin_magic.len } - _ := r.u8_()! // version + ver := r.u8_()! // format version mut bin := Bin{} nfn := int(r.u32_()!) for _ in 0..nfn { @@ -273,5 +314,14 @@ pub fn read_bin(path string) !Bin { line := int(r.i64_()!) bin.lines << LineInfo{ off: off, line: line } } + if ver >= 2 { + nlocals := int(r.u32_()!) + for _ in 0..nlocals { + fn_name := r.read_str()! + name := r.read_str()! + slot := int(r.i64_()!) + bin.locals << DbgLocal{ fn: fn_name, name: name, slot: slot } + } + } return bin } diff --git a/vm/dbg.v b/vm/dbg.v new file mode 100644 index 0000000..55f824c --- /dev/null +++ b/vm/dbg.v @@ -0,0 +1,178 @@ +// dbg.v — the interactive debugger, enabled by `vr debug`. +// +// While enabled the VM checks dbg_tick before every instruction. When a +// breakpoint is hit (or a step/next/finish condition is met) it drops into +// dbg_session, a small command loop over stdin: +// +// c | continue resume until the next breakpoint (or the end) +// s | step execute one instruction +// n | next run to the next line of the current frame (skip calls) +// f | finish run until the current function returns +// b [line] set a breakpoint on a source line (no arg: list them) +// d delete breakpoint n (1-based) +// p print a local variable's value +// l | locals list the current function's locals and their values +// bt | stack print the call chain +// q | quit abort the program +// +// Reading EOF (e.g. piped input) is treated as `continue`, so scripts can +// drive a session non-interactively. +module vm + +import os + +// dbg_tick runs once per instruction while the debugger is enabled and +// decides whether to stop and open an interactive session. +fn (mut v Vm) dbg_tick() ! { + line := v.line_at(v.ip) + mut stop := false + match v.dbg.mode { + .step { + stop = true + } + .next { + // run until the line changes while still at (or above) the frame + // level where next began — calls push deeper frames, which we skip + stop = v.bp <= v.dbg.start_bp && line != v.dbg.last_line + } + .finish { + // run until the current frame returns to its caller + stop = v.bp < v.dbg.start_bp + } + .run { + // stop at the first instruction of a breakpoint line only, so a + // multi-instruction line does not re-trigger mid-line + if line in v.dbg.breakpoints { + first := v.ip == 0 || v.line_at(v.ip - 1) != line + stop = first + } + } + } + if stop { + v.dbg_session()! + } +} + +// dbg_session is the interactive command loop. It returns when the user +// chooses a resume mode (continue/step/next/finish) or quits. +fn (mut v Vm) dbg_session() ! { + v.dbg.last_line = v.line_at(v.ip) + println('') + println('== stopped at ${v.func_at(v.ip)} (line ${v.dbg.last_line}, ip ${v.ip}) — help: h') + for { + input := os.input_opt('(vr-dbg) ') or { 'c' } // EOF → continue + parts := input.trim_space().split(' ') + cmd := parts[0] + arg := if parts.len > 1 { parts[1] } else { '' } + match cmd { + 'c', 'continue', '' { + v.dbg.mode = .run + return + } + 's', 'step' { + v.dbg.mode = .step + return + } + 'n', 'next' { + v.dbg.mode = .next + v.dbg.start_bp = v.bp + return + } + 'f', 'finish' { + v.dbg.mode = .finish + v.dbg.start_bp = v.bp + return + } + 'b', 'break' { + if arg == '' { + if v.dbg.breakpoints.len == 0 { + println(' no breakpoints set') + } else { + for i, bp in v.dbg.breakpoints { + println(' ${i + 1}: line ${bp}') + } + } + } else { + line := arg.int() + if line <= 0 { + println(' usage: b ') + } else if line !in v.dbg.breakpoints { + v.dbg.breakpoints << line + println(' breakpoint set at line ${line}') + } + } + } + 'd', 'delete' { + n := arg.int() + if n >= 1 && n <= v.dbg.breakpoints.len { + v.dbg.breakpoints.delete(n - 1) + println(' breakpoint ${n} deleted') + } else { + println(' usage: d (see `b` for the list)') + } + } + 'p', 'print' { + if arg == '' { + println(' usage: p ') + } else { + v.dbg_print_local(arg) + } + } + 'l', 'locals' { + v.dbg_list_locals() + } + 'bt', 'stack', 'backtrace' { + println(v.stack_trace()) + } + 'h', 'help' { + println(' c continue · s step · n next · f finish · b [line] · d ') + println(' p · l locals · bt stack · q quit') + } + 'q', 'quit', 'exit' { + v.halted = true + return + } + else { + println(' unknown command "${cmd}" — h for help') + } + } + } +} + +// dbg_print_local prints the value of one local variable of the current +// function, resolving its slot from the debug locals table. +fn (mut v Vm) dbg_print_local(name string) { + fn_name := v.func_at(v.ip) + slot := v.dbg_slot(fn_name, name) + if slot < 0 { + println(' no local "${name}" in ${fn_name}') + return + } + println(' ${name} = ${v.val_str(v.stack[v.bp + slot], 0)}') +} + +// dbg_list_locals prints every named local of the current function with its +// current value. +fn (mut v Vm) dbg_list_locals() { + fn_name := v.func_at(v.ip) + mut found := false + for l in v.dbg_locals { + if l.fn == fn_name { + found = true + println(' ${l.name} = ${v.val_str(v.stack[v.bp + l.slot], 0)} (slot ${l.slot})') + } + } + if !found { + println(' (no named locals for ${fn_name})') + } +} + +// dbg_slot finds the stack slot of a local by (function, name), or -1. +fn (v Vm) dbg_slot(fn_name string, name string) int { + for l in v.dbg_locals { + if l.fn == fn_name && l.name == name { + return l.slot + } + } + return -1 +} diff --git a/vm/native.v b/vm/native.v index e601383..d067880 100644 --- a/vm/native.v +++ b/vm/native.v @@ -236,16 +236,11 @@ fn (mut v Vm) native(id int, _argc int) ! { return error('sort() expects an array') } mut a := v.arrays[v.hand(h)] - // insertion sort by numeric value - for i in 1..a.len { - key := a[i] - mut j := i - 1 - for j >= 0 && v.num_gt(a[j], key) { - a[j + 1] = a[j] - j-- - } - a[j + 1] = key - } + // merge sort: guaranteed O(n log n) (insertion sort was O(n^2) on + // large or reversed inputs) and stable, so equal elements keep + // their original order + mut tmp := []i64{len: a.len} + v.merge_sort(mut a, mut tmp, 0, a.len) v.push(h)! } native_clone { @@ -1099,6 +1094,45 @@ fn (mut v Vm) str_method(name string, argc int) ! { return error('unknown string method "${name}"') } +// merge_sort sorts a[lo..hi) ascending by numeric value, using tmp as the +// scratch buffer (must be at least hi long). It is stable: equal elements +// keep their relative order. +fn (mut v Vm) merge_sort(mut a []i64, mut tmp []i64, lo int, hi int) { + if hi - lo <= 1 { + return + } + mid := lo + (hi - lo) / 2 + v.merge_sort(mut a, mut tmp, lo, mid) + v.merge_sort(mut a, mut tmp, mid, hi) + mut i := lo + mut j := mid + mut k := lo + for i < mid && j < hi { + if !v.num_gt(a[i], a[j]) { + // a[i] <= a[j]: take from the left half (equal -> left, so stable) + tmp[k] = a[i] + i++ + } else { + tmp[k] = a[j] + j++ + } + k++ + } + for i < mid { + tmp[k] = a[i] + i++ + k++ + } + for j < hi { + tmp[k] = a[j] + j++ + k++ + } + for x in lo..hi { + a[x] = tmp[x] + } +} + // num_gt compares two values by their numeric value (int or float). fn (mut v Vm) num_gt(x i64, y i64) bool { if v.is_float(x) || v.is_float(y) { diff --git a/vm/types.v b/vm/types.v index 830519a..3b86b59 100644 --- a/vm/types.v +++ b/vm/types.v @@ -34,6 +34,30 @@ struct Handler { sp int // stack pointer right after the handler record } +// DbgMode says what the debugger should do after an interactive session ends. +enum DbgMode { + run // keep going until the next breakpoint (or the end) + step // stop at the very next instruction + next // stop after the current line returns to this frame level + finish // stop when the current function returns +} + +// DbgState is the interactive debugger's runtime state, checked once per +// instruction while enabled. +struct DbgState { +mut: + enabled bool + breakpoints []int // source lines to stop at (first instruction of the line) + mode DbgMode + start_bp int // frame base captured when next/finish began + last_line int // line at the moment the session stopped +} + +struct FnEntry { + idx int + entry int +} + struct Vm { mut: code []u8 @@ -57,6 +81,14 @@ mut: const_strs int // strings[0..const_strs] are bytecode constants, never collected last_heap int // heap size at the last GC check (allocation trigger) build_root string // directory of the .vrmm build module (build_root() builtin) + dbg DbgState // interactive debugger state (vr debug) + dbg_locals []obj.DbgLocal // local name -> slot per function (debugger) + profiling bool // instruction/call counting (vr run --profile) + prof_instr []u64 // instructions executed per function index + prof_calls []u64 // calls made per function index + fn_of_ip []int // code offset -> function index (for profiling) + max_ops i64 // instruction budget; 0 = unlimited (fuzzing safety) + ops i64 // instructions executed so far } fn bool_i64(b bool) i64 { diff --git a/vm/vm.v b/vm/vm.v index 4c8e090..1ef11b4 100644 --- a/vm/vm.v +++ b/vm/vm.v @@ -8,6 +8,19 @@ module vm import obj import math +// RunOpts configures a VM run: tracing, program arguments, the build root, +// the interactive debugger, an instruction budget, and profiling. +pub struct RunOpts { +pub: + trace bool + args []string = [] + root string + debug bool // start the interactive debugger (vr debug) + breakpoints []int // source lines to stop at; empty + debug = stop at entry + max_ops i64 // instruction budget; 0 = unlimited + profile bool // count instructions/calls per function +} + // run executes the function named `entry` from the executable `bin` and // returns its return value (0 if it never returns one). pub fn run(bin obj.Bin, entry string, trace bool) !i64 { @@ -17,27 +30,95 @@ pub fn run(bin obj.Bin, entry string, trace bool) !i64 { // run_with_args is run() with command-line arguments exposed to the program // via the `args()` builtin. pub fn run_with_args(bin obj.Bin, entry string, trace bool, args []string) !i64 { - return run_internal(bin, entry, trace, args, '')! + return run_opts(bin, entry, RunOpts{ trace: trace, args: args })! } // run_build executes a .vrmm build module: the entry target receives the // extra CLI arguments via `args()`, and `build_root()` reports the module's // own directory so scripts can find files regardless of the working directory. pub fn run_build(bin obj.Bin, entry string, args []string, root string) !i64 { - return run_internal(bin, entry, false, args, root)! + return run_opts(bin, entry, RunOpts{ args: args, root: root })! } -fn run_internal(bin obj.Bin, entry string, trace bool, args []string, root string) !i64 { +// run_opts runs the program with full control over the runtime options. +pub fn run_opts(bin obj.Bin, entry string, opts RunOpts) !i64 { + mut v := new_vm(bin, entry, opts)! + return v.run_result()! +} + +// run_debug runs the program under the interactive debugger, stopping at the +// given source-line breakpoints (or at entry when none are given). +pub fn run_debug(bin obj.Bin, entry string, breakpoints []int, args []string) !i64 { + return run_opts(bin, entry, RunOpts{ args: args, debug: true, breakpoints: breakpoints })! +} + +// ProfileRow is one function's profile totals. +pub struct ProfileRow { +pub: + name string + calls u64 + instr u64 +} + +// ProfileReport is the result of a profiled run: per-function instruction +// and call counts, sorted by instructions executed (hot first). +pub struct ProfileReport { +pub: + rows []ProfileRow + total u64 // instructions executed across all functions +} + +// run_profiled executes the program counting instructions and calls per +// function, and returns the report. +pub fn run_profiled(bin obj.Bin, entry string, args []string) !ProfileReport { + mut v := new_vm(bin, entry, RunOpts{ args: args, profile: true })! + _ = v.run_result()! + mut rows := []ProfileRow{} + for i in 0..v.fns.len { + rows << ProfileRow{ name: v.fns[i].name, calls: v.prof_calls[i], instr: v.prof_instr[i] } + } + rows.sort_with_compare(fn (a &ProfileRow, b &ProfileRow) int { + if a.instr > b.instr { + return -1 + } + if a.instr < b.instr { + return 1 + } + return 0 + }) + mut total := u64(0) + for r in rows { + total += r.instr + } + return ProfileReport{ rows: rows, total: total } +} + +// new_vm builds a configured Vm for the entry function, pushing the synthetic +// entry frame and pointing ip at the entry point. +fn new_vm(bin obj.Bin, entry string, opts RunOpts) !Vm { mut v := Vm{ code: bin.code strings: bin.strings.clone() stack: []i64{len: stack_cap} - trace: trace - prog_args: args + trace: opts.trace + prog_args: opts.args lines: bin.lines fns: bin.fns const_strs: bin.strings.len - build_root: root + build_root: opts.root + dbg_locals: bin.locals + max_ops: opts.max_ops + } + if opts.profile { + v.profiling = true + v.prof_instr = []u64{len: v.fns.len} + v.prof_calls = []u64{len: v.fns.len} + v.fn_of_ip = v.build_fn_of_ip() + } + if opts.debug { + v.dbg.enabled = true + v.dbg.breakpoints = opts.breakpoints.clone() + v.dbg.mode = if opts.breakpoints.len > 0 { DbgMode.run } else { DbgMode.step } } mut entry_ip := -1 for f in bin.fns { @@ -61,6 +142,11 @@ fn run_internal(bin obj.Bin, entry string, trace bool, args []string, root strin v.sp++ v.bp = v.sp v.ip = entry_ip + return v +} + +// run_result executes until halt/error and extracts the program's result. +fn (mut v Vm) run_result() !i64 { v.exec() or { return error('${err.msg()} at ${v.where()}\n${v.stack_trace()}') } @@ -73,6 +159,29 @@ fn run_internal(bin obj.Bin, entry string, trace bool, args []string, root strin return 0 } +// build_fn_of_ip precomputes, for every code offset, the index of the +// function that contains it, so profiling adds one array lookup per opcode. +// Function tables are not guaranteed to be in entry order (the linker builds +// them from a map), so entries are sorted by offset first. +fn (v Vm) build_fn_of_ip() []int { + mut out := []int{len: v.code.len} + mut fes := []FnEntry{} + for i, f in v.fns { + fes << FnEntry{ idx: i, entry: f.entry } + } + fes.sort_with_compare(fn (a &FnEntry, b &FnEntry) int { + return a.entry - b.entry + }) + mut fi := 0 + for ip in 0..v.code.len { + for fi + 1 < fes.len && fes[fi + 1].entry <= ip { + fi++ + } + out[ip] = fes[fi].idx + } + return out +} + // where returns a source-level location for the current instruction pointer: // `line 12 (ip 345)` when debug info is available, otherwise just `(ip 345)`. fn (v Vm) where() string { @@ -145,6 +254,18 @@ fn (mut v Vm) exec() ! { if v.trace { v.trace_op(op) } + if v.profiling { + v.prof_instr[v.fn_of_ip[v.ip]]++ + } + if v.max_ops > 0 { + v.ops++ + if v.ops > v.max_ops { + return error('max ops exceeded (${v.max_ops}) — possible infinite loop') + } + } + if v.dbg.enabled { + v.dbg_tick()! + } match op { op_halt { v.halted = true @@ -305,6 +426,9 @@ fn (mut v Vm) exec() ! { v.ip++ target := int(v.read_i64()) argc := int(v.read_i64()) + if v.profiling { + v.prof_calls[v.fn_of_ip[target]]++ + } v.call(target, argc) } op_ret { @@ -633,6 +757,9 @@ fn (mut v Vm) exec() ! { v.stack[c + i] = cl.captured[i] } v.sp = c + n + argc + if v.profiling { + v.prof_calls[v.fn_of_ip[cl.entry]]++ + } v.call(cl.entry, argc + n) } op_argc {