From 0fb25b547aa0e763acffedf4c4096f1bd7a80b4a Mon Sep 17 00:00:00 2001 From: Marc Vertes Date: Thu, 20 Feb 2025 10:56:58 +0100 Subject: [PATCH] feat(gnovm): enable debugger for gno test (#3380) This change brings interactive debugging to gno tests, using -debug flag. New debugger commands have also been added: - next, to step over the next source line - stepout, to step out of the current function Usage example, from the clone root dir: ```console $ go run ./gnovm/cmd/gno test -v -debug ./examples/gno.land/p/demo/diff -run TestMyersDiff/Case_sensitivity Welcome to the Gnovm debugger. Type 'help' for list of commands. dbg> break gno.land/p/demo/diff/diff.gno:60 Breakpoint 0 at gno.land/p/demo/diff gno.land/p/demo/diff/diff.gno:60:0 dbg> c === RUN TestMyersDiff === RUN TestMyersDiff/Case_sensitivity > diff.MyersDiff() gno.land/p/demo/diff/diff.gno:60:10 55: // 56: // Returns: 57: // - A slice of Edit operations representing the minimum difference between the two strings. 58: func MyersDiff(old, new string) []Edit { 59: oldRunes, newRunes := []rune(old), []rune(new) => 60: n, m := len(oldRunes), len(newRunes) 61: 62: if n == 0 && m == 0 { 63: return []Edit{} 64: } dbg> p oldRunes (slice[(97 int32),(98 int32),(99 int32)] []int32) dbg> stack 0 in gno.land/p/demo/diff.MyersDiff at gno.land/p/demo/diff/diff.gno:60:10 1 in gno.land/p/demo/diff. at gno.land/p/demo/diff/diff_test.gno:176:4 2 in testing.tRunner at testing/testing.gno:364:2 3 in testing.(*testing.T).Run at testing/testing.gno:171:2 4 in gno.land/p/demo/diff.TestMyersDiff at gno.land/p/demo/diff/diff_test.gno:175:3 5 in testing.tRunner at testing/testing.gno:364:2 6 in testing.RunTest at testing/testing.gno:298:2 dbg> ``` --- gnovm/cmd/gno/test.go | 17 ++++++ gnovm/cmd/gno/tool_lint.go | 3 +- gnovm/pkg/gnolang/debugger.go | 98 +++++++++++++++++++++++------- gnovm/pkg/gnolang/debugger_test.go | 3 + gnovm/pkg/repl/repl.go | 2 +- gnovm/pkg/test/filetest.go | 1 + gnovm/pkg/test/test.go | 29 +++++++-- 7 files changed, 125 insertions(+), 28 deletions(-) diff --git a/gnovm/cmd/gno/test.go b/gnovm/cmd/gno/test.go index ae67d69bc90..67cba458aa7 100644 --- a/gnovm/cmd/gno/test.go +++ b/gnovm/cmd/gno/test.go @@ -26,6 +26,8 @@ type testCfg struct { updateGoldenTests bool printRuntimeMetrics bool printEvents bool + debug bool + debugAddr string } func newTestCmd(io commands.IO) *commands.Command { @@ -143,6 +145,20 @@ func (c *testCfg) RegisterFlags(fs *flag.FlagSet) { false, "print emitted events", ) + + fs.BoolVar( + &c.debug, + "debug", + false, + "enable interactive debugger using stdin and stdout", + ) + + fs.StringVar( + &c.debugAddr, + "debug-addr", + "", + "enable interactive debugger using tcp address in the form [host]:port", + ) } func execTest(cfg *testCfg, args []string, io commands.IO) error { @@ -189,6 +205,7 @@ func execTest(cfg *testCfg, args []string, io commands.IO) error { opts.Verbose = cfg.verbose opts.Metrics = cfg.printRuntimeMetrics opts.Events = cfg.printEvents + opts.Debug = cfg.debug buildErrCount := 0 testErrCount := 0 diff --git a/gnovm/cmd/gno/tool_lint.go b/gnovm/cmd/gno/tool_lint.go index 6983175cea0..812a2baf103 100644 --- a/gnovm/cmd/gno/tool_lint.go +++ b/gnovm/cmd/gno/tool_lint.go @@ -159,7 +159,8 @@ func execLint(cfg *lintCfg, args []string, io commands.IO) error { io.ErrPrintfln("%s: module is draft, skipping type check", pkgPath) } - tm := test.Machine(gs, goio.Discard, memPkg.Path) + tm := test.Machine(gs, goio.Discard, memPkg.Path, false) + defer tm.Release() // Check test files diff --git a/gnovm/pkg/gnolang/debugger.go b/gnovm/pkg/gnolang/debugger.go index f047a176af7..6b7b2f77175 100644 --- a/gnovm/pkg/gnolang/debugger.go +++ b/gnovm/pkg/gnolang/debugger.go @@ -10,11 +10,14 @@ import ( "io" "net" "os" + "path" "path/filepath" "sort" "strconv" "strings" "unicode" + + "github.com/gnolang/gno/gnovm/pkg/gnoenv" ) // DebugState is the state of the machine debugger, defined by a finite state @@ -43,24 +46,28 @@ type Debugger struct { out io.Writer // debugger output, defaults to Stdout scanner *bufio.Scanner // to parse input per line - state DebugState // current state of debugger - lastCmd string // last debugger command - lastArg string // last debugger command arguments - loc Location // source location of the current machine instruction - prevLoc Location // source location of the previous machine instruction - breakpoints []Location // list of breakpoints set by user, as source locations - call []Location // for function tracking, ideally should be provided by machine frame - frameLevel int // frame level of the current machine instruction - getSrc func(string) string // helper to access source from repl or others + state DebugState // current state of debugger + lastCmd string // last debugger command + lastArg string // last debugger command arguments + loc Location // source location of the current machine instruction + prevLoc Location // source location of the previous machine instruction + nextLoc Location // source location at the 'next' command + breakpoints []Location // list of breakpoints set by user, as source locations + call []Location // for function tracking, ideally should be provided by machine frame + frameLevel int // frame level of the current machine instruction + nextDepth int // function call depth at the 'next' command + getSrc func(string, string) string // helper to access source from repl or others + rootDir string } // Enable makes the debugger d active, using in as input reader, out as output writer and f as a source helper. -func (d *Debugger) Enable(in io.Reader, out io.Writer, f func(string) string) { +func (d *Debugger) Enable(in io.Reader, out io.Writer, f func(string, string) string) { d.in = in d.out = out d.enabled = true d.state = DebugAtInit d.getSrc = f + d.rootDir = gnoenv.RootDir() } // Disable makes the debugger d inactive. @@ -92,11 +99,11 @@ func init() { "list": {debugList, listUsage, listShort, listLong}, "print": {debugPrint, printUsage, printShort, ""}, "stack": {debugStack, stackUsage, stackShort, ""}, - // NOTE: the difference between continue, step and stepi is handled within - // the main Debug() loop. - "step": {debugContinue, stepUsage, stepShort, ""}, - "stepi": {debugContinue, stepiUsage, stepiShort, ""}, - "up": {debugUp, upUsage, upShort, ""}, + "next": {debugContinue, nextUsage, nextShort, ""}, + "step": {debugContinue, stepUsage, stepShort, ""}, + "stepi": {debugContinue, stepiUsage, stepiShort, ""}, + "stepout": {debugContinue, stepoutUsage, stepoutShort, ""}, + "up": {debugUp, upUsage, upShort, ""}, } // Sort command names for help. @@ -113,11 +120,13 @@ func init() { debugCmds["c"] = debugCmds["continue"] debugCmds["h"] = debugCmds["help"] debugCmds["l"] = debugCmds["list"] + debugCmds["n"] = debugCmds["next"] debugCmds["p"] = debugCmds["print"] debugCmds["quit"] = debugCmds["exit"] debugCmds["q"] = debugCmds["exit"] debugCmds["s"] = debugCmds["step"] debugCmds["si"] = debugCmds["stepi"] + debugCmds["so"] = debugCmds["stepout"] } // Debug is the debug callback invoked at each VM execution step. It implements the DebugState FSA. @@ -135,6 +144,9 @@ loop: fmt.Fprintln(m.Debugger.out, "Command failed:", err) } case DebugAtRun: + if !m.Debugger.enabled { + break loop + } switch m.Debugger.lastCmd { case "si", "stepi": m.Debugger.state = DebugAtCmd @@ -146,6 +158,21 @@ loop: debugList(m, "") continue loop } + case "n", "next": + if m.Debugger.loc != m.Debugger.prevLoc && m.Debugger.loc.File != "" && + (m.Debugger.nextDepth == 0 || !sameLine(m.Debugger.loc, m.Debugger.nextLoc) && callDepth(m) <= m.Debugger.nextDepth) { + m.Debugger.state = DebugAtCmd + m.Debugger.prevLoc = m.Debugger.loc + debugList(m, "") + continue loop + } + case "stepout", "so": + if callDepth(m) < m.Debugger.nextDepth { + m.Debugger.state = DebugAtCmd + m.Debugger.prevLoc = m.Debugger.loc + debugList(m, "") + continue loop + } default: if atBreak(m) { m.Debugger.state = DebugAtCmd @@ -172,6 +199,23 @@ loop: } } +// callDepth returns the function call depth. +func callDepth(m *Machine) int { + n := 0 + for _, f := range m.Frames { + if f.Func == nil { + continue + } + n++ + } + return n +} + +// sameLine returns true if both arguments are at the same line. +func sameLine(loc1, loc2 Location) bool { + return loc1.PkgPath == loc2.PkgPath && loc1.File == loc2.File && loc1.Line == loc2.Line +} + // atBreak returns true if current machine location matches a breakpoint, false otherwise. func atBreak(m *Machine) bool { loc := m.Debugger.loc @@ -317,7 +361,14 @@ func parseLocSpec(m *Machine, arg string) (loc Location, err error) { if loc.File, err = filepath.Abs(strs[0]); err != nil { return loc, err } - loc.File = filepath.Clean(loc.File) + loc.File = path.Clean(loc.File) + if m.Debugger.rootDir != "" && strings.HasPrefix(loc.File, m.Debugger.rootDir) { + loc.File = strings.TrimPrefix(loc.File, m.Debugger.rootDir+"/gnovm/stdlibs/") + loc.File = strings.TrimPrefix(loc.File, m.Debugger.rootDir+"/examples/") + loc.File = strings.TrimPrefix(loc.File, m.Debugger.rootDir+"/") + loc.PkgPath = path.Dir(loc.File) + loc.File = path.Base(loc.File) + } } if line, err = strconv.Atoi(strs[1]); err != nil { return loc, err @@ -378,24 +429,29 @@ func debugClear(m *Machine, arg string) error { } // --------------------------------------- +// NOTE: the difference between continue, next, step, stepi and stepout is handled within the Debug() loop. const ( continueUsage = `continue|c` continueShort = `Run until breakpoint or program termination.` -) -const ( + nextUsage = `next|n` + nextShort = `Step over to next source line.` + stepUsage = `step|s` stepShort = `Single step through program.` -) -const ( stepiUsage = `stepi|si` stepiShort = `Single step a single VM instruction.` + + stepoutUsage = `stepout|so` + stepoutShort = `Step out of the current function.` ) func debugContinue(m *Machine, arg string) error { m.Debugger.state = DebugAtRun m.Debugger.frameLevel = 0 + m.Debugger.nextDepth = callDepth(m) + m.Debugger.nextLoc = m.Debugger.loc return nil } @@ -505,7 +561,7 @@ func debugList(m *Machine, arg string) (err error) { if err != nil { // Use optional getSrc helper as fallback to get source. if m.Debugger.getSrc != nil { - src = m.Debugger.getSrc(loc.File) + src = m.Debugger.getSrc(loc.PkgPath, loc.File) } if src == "" { return err diff --git a/gnovm/pkg/gnolang/debugger_test.go b/gnovm/pkg/gnolang/debugger_test.go index a9e0a4834d5..a4282225324 100644 --- a/gnovm/pkg/gnolang/debugger_test.go +++ b/gnovm/pkg/gnolang/debugger_test.go @@ -143,6 +143,9 @@ func TestDebug(t *testing.T) { {in: "b 27\nc\np b\n", out: `("!zero" string)`}, {in: "b 22\nc\np t.A[3]\n", out: "Command failed: &{(\"slice index out of bounds: 3 (len=3)\" string) }"}, {in: "b 43\nc\nc\nc\np i\ndetach\n", out: "(1 int)"}, + {in: "b 37\nc\nnext\n", out: "=> 39:"}, + {in: "b 40\nc\nnext\n", out: "=> 41:"}, + {in: "b 22\nc\nstepout\n", out: "=> 40:"}, }) runDebugTest(t, "../../tests/files/a1.gno", []dtest{ diff --git a/gnovm/pkg/repl/repl.go b/gnovm/pkg/repl/repl.go index fff80d672dc..56c8b4a53f3 100644 --- a/gnovm/pkg/repl/repl.go +++ b/gnovm/pkg/repl/repl.go @@ -156,7 +156,7 @@ func (r *Repl) Process(input string) (out string, err error) { r.state.id++ if r.debug { - r.state.machine.Debugger.Enable(os.Stdin, os.Stdout, func(file string) string { + r.state.machine.Debugger.Enable(os.Stdin, os.Stdout, func(ppath, file string) string { return r.state.files[file] }) r.debug = false diff --git a/gnovm/pkg/test/filetest.go b/gnovm/pkg/test/filetest.go index c24c014a9ba..61e6084aa23 100644 --- a/gnovm/pkg/test/filetest.go +++ b/gnovm/pkg/test/filetest.go @@ -63,6 +63,7 @@ func (opts *TestOptions) runFiletest(filename string, source []byte) (string, er Store: opts.TestStore.BeginTransaction(cw, cw, nil), Context: ctx, MaxAllocBytes: maxAlloc, + Debug: opts.Debug, }) defer m.Release() result := opts.runTest(m, pkgPath, filename, source) diff --git a/gnovm/pkg/test/test.go b/gnovm/pkg/test/test.go index 71ec3bb2568..f9b7f47dd29 100644 --- a/gnovm/pkg/test/test.go +++ b/gnovm/pkg/test/test.go @@ -70,11 +70,12 @@ func Context(pkgPath string, send std.Coins) *teststd.TestExecContext { } // Machine is a minimal machine, set up with just the Store, Output and Context. -func Machine(testStore gno.Store, output io.Writer, pkgPath string) *gno.Machine { +func Machine(testStore gno.Store, output io.Writer, pkgPath string, debug bool) *gno.Machine { return gno.NewMachineWithOptions(gno.MachineOptions{ Store: testStore, Output: output, Context: Context(pkgPath, nil), + Debug: debug, }) } @@ -107,6 +108,8 @@ type TestOptions struct { Output io.Writer // Used for os.Stderr, and for printing errors. Error io.Writer + // Debug enables the interactive debugger on gno tests. + Debug bool // Not set by NewTestOptions: @@ -289,9 +292,8 @@ func (opts *TestOptions) runTestFiles( // reset store ops, if any - we only need them for some filetests. opts.TestStore.SetLogStoreOps(false) - // Check if we already have the package - it may have been eagerly - // loaded. - m = Machine(gs, opts.WriterForStore(), memPkg.Path) + // Check if we already have the package - it may have been eagerly loaded. + m = Machine(gs, opts.WriterForStore(), memPkg.Path, opts.Debug) m.Alloc = alloc if opts.TestStore.GetMemPackage(memPkg.Path) == nil { m.RunMemPackage(memPkg, true) @@ -311,7 +313,7 @@ func (opts *TestOptions) runTestFiles( // - Run the test files before this for loop (but persist it to store; // RunFiles doesn't do that currently) // - Wrap here. - m = Machine(gs, opts.Output, memPkg.Path) + m = Machine(gs, opts.Output, memPkg.Path, opts.Debug) m.Alloc = alloc.Reset() m.SetActivePackage(pv) @@ -319,6 +321,23 @@ func (opts *TestOptions) runTestFiles( testingtv := gno.TypedValue{T: &gno.PackageType{}, V: testingpv} testingcx := &gno.ConstExpr{TypedValue: testingtv} + if opts.Debug { + fileContent := func(ppath, name string) string { + p := filepath.Join(opts.RootDir, ppath, name) + b, err := os.ReadFile(p) + if err != nil { + p = filepath.Join(opts.RootDir, "gnovm", "stdlibs", ppath, name) + b, err = os.ReadFile(p) + } + if err != nil { + p = filepath.Join(opts.RootDir, "examples", ppath, name) + b, err = os.ReadFile(p) + } + return string(b) + } + m.Debugger.Enable(os.Stdin, os.Stdout, fileContent) + } + eval := m.Eval(gno.Call( gno.Sel(testingcx, "RunTest"), // Call testing.RunTest gno.Str(opts.RunFlag), // run flag