Skip to content

Commit

Permalink
feat(gnovm): enable debugger for gno test (#3380)
Browse files Browse the repository at this point in the history
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>
```
  • Loading branch information
mvertes authored Feb 20, 2025
1 parent 955e1ca commit 0fb25b5
Show file tree
Hide file tree
Showing 7 changed files with 125 additions and 28 deletions.
17 changes: 17 additions & 0 deletions gnovm/cmd/gno/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ type testCfg struct {
updateGoldenTests bool
printRuntimeMetrics bool
printEvents bool
debug bool
debugAddr string
}

func newTestCmd(io commands.IO) *commands.Command {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion gnovm/cmd/gno/tool_lint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 77 additions & 21 deletions gnovm/pkg/gnolang/debugger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions gnovm/pkg/gnolang/debugger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) <nil> }"},
{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{
Expand Down
2 changes: 1 addition & 1 deletion gnovm/pkg/repl/repl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions gnovm/pkg/test/filetest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 24 additions & 5 deletions gnovm/pkg/test/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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)
Expand All @@ -311,14 +313,31 @@ 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)

testingpv := m.Store.GetPackage("testing", false)
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
Expand Down

0 comments on commit 0fb25b5

Please sign in to comment.