diff --git a/gnovm/cmd/gno/main_test.go b/gnovm/cmd/gno/main_test.go index 1395d120012..1797d0aede9 100644 --- a/gnovm/cmd/gno/main_test.go +++ b/gnovm/cmd/gno/main_test.go @@ -90,7 +90,7 @@ func testMainCaseRun(t *testing.T, tc []testMainCase) { if r := recover(); r != nil { output := fmt.Sprintf("%v", r) t.Log("recover", output) - require.False(t, recoverShouldBeEmpty, "should panic") + require.False(t, recoverShouldBeEmpty, "should not panic") require.True(t, errShouldBeEmpty, "should not return an error") if test.recoverShouldContain != "" { require.Regexpf(t, test.recoverShouldContain, output, "recover should contain") @@ -100,7 +100,7 @@ func testMainCaseRun(t *testing.T, tc []testMainCase) { } checkOutputs(t) } else { - require.True(t, recoverShouldBeEmpty, "should not panic") + require.True(t, recoverShouldBeEmpty, "should panic") } }() diff --git a/gnovm/cmd/gno/run.go b/gnovm/cmd/gno/run.go index ed4481a223c..0c5218613a9 100644 --- a/gnovm/cmd/gno/run.go +++ b/gnovm/cmd/gno/run.go @@ -16,9 +16,11 @@ import ( ) type runCfg struct { - verbose bool - rootDir string - expr string + verbose bool + rootDir string + expr string + debug bool + debugAddr string } func newRunCmd(io commands.IO) *commands.Command { @@ -58,6 +60,20 @@ func (c *runCfg) RegisterFlags(fs *flag.FlagSet) { "main()", "value of expression to evaluate. Defaults to executing function main() with no args", ) + + 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 execRun(cfg *runCfg, args []string, io commands.IO) error { @@ -97,12 +113,21 @@ func execRun(cfg *runCfg, args []string, io commands.IO) error { m := gno.NewMachineWithOptions(gno.MachineOptions{ PkgPath: string(files[0].PkgName), + Input: stdin, Output: stdout, Store: testStore, + Debug: cfg.debug || cfg.debugAddr != "", }) defer m.Release() + // If the debug address is set, the debugger waits for a remote client to connect to it. + if cfg.debugAddr != "" { + if err := m.Debugger.Serve(cfg.debugAddr); err != nil { + return err + } + } + // run files m.RunFiles(files...) runExpr(m, cfg.expr) diff --git a/gnovm/cmd/gno/run_test.go b/gnovm/cmd/gno/run_test.go index b0cc1514bcb..f78c15edb34 100644 --- a/gnovm/cmd/gno/run_test.go +++ b/gnovm/cmd/gno/run_test.go @@ -67,6 +67,14 @@ func TestRunApp(t *testing.T) { args: []string{"run", "../../tests/integ/undefined_variable_test/undefined_variables_test.gno"}, recoverShouldContain: "--- preprocess stack ---", // should contain preprocess debug stack trace }, + { + args: []string{"run", "-debug", "../../tests/integ/debugger/sample.gno"}, + stdoutShouldContain: "Welcome to the Gnovm debugger", + }, + { + args: []string{"run", "-debug-addr", "invalidhost:17538", "../../tests/integ/debugger/sample.gno"}, + errShouldContain: "listen tcp: lookup invalidhost", + }, // TODO: a test file // TODO: args // TODO: nativeLibs VS stdlibs diff --git a/gnovm/pkg/gnolang/debugger.go b/gnovm/pkg/gnolang/debugger.go new file mode 100644 index 00000000000..2a080da47a3 --- /dev/null +++ b/gnovm/pkg/gnolang/debugger.go @@ -0,0 +1,780 @@ +package gnolang + +import ( + "bufio" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "io" + "net" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "unicode" +) + +// DebugState is the state of the machine debugger, defined by a finite state +// automaton with the following transitions, evaluated at each debugger input +// or each gnoVM instruction while in step mode: +// - DebugAtInit -> DebugAtCmd: initial debugger setup is performed +// - DebugAtCmd -> DebugAtCmd: when command is for inspecting or setting a breakpoint +// - DebugAtCmd -> DebuAtRun: when command is 'continue', 'step' or 'stepi' +// - DebugAtCmd -> DebugAtExit: when command is 'quit' or 'resume' +// - DebugAtRun -> DebugAtRun: when current machine instruction doesn't match a breakpoint +// - DebugAtRun -> DebugAtCmd: when current machine instruction matches a breakpoint +// - DebugAtRun -> DebugAtExit: when the program terminates +type DebugState int + +const ( + DebugAtInit DebugState = iota // performs debugger IO setup and enters gnoVM in step mode + DebugAtCmd // awaits a new command from the debugger input stream + DebugAtRun // awaits the next machine instruction + DebugAtExit // closes debugger IO and exits gnoVM from step mode +) + +// Debugger describes a machine debugger. +type Debugger struct { + enabled bool // when true, machine is in step mode + in io.Reader // debugger input, defaults to Stdin + 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 +} + +type debugCommand struct { + debugFunc func(*Machine, string) error // debug command + usage, short, long string // command help texts +} + +var ( + debugCmds map[string]debugCommand + debugCmdNames []string +) + +func init() { + // Register debugger commands. + debugCmds = map[string]debugCommand{ + "break": {debugBreak, breakUsage, breakShort, breakLong}, + "breakpoints": {debugBreakpoints, breakpointsUsage, breakpointsShort, ""}, + "clear": {debugClear, clearUsage, clearShort, ""}, + "continue": {debugContinue, continueUsage, continueShort, ""}, + "detach": {debugDetach, detachUsage, detachShort, ""}, + "down": {debugDown, downUsage, downShort, ""}, + "exit": {debugExit, exitUsage, exitShort, ""}, + "help": {debugHelp, helpUsage, helpShort, ""}, + "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, ""}, + } + + // Sort command names for help. + debugCmdNames = make([]string, 0, len(debugCmds)) + for name := range debugCmds { + debugCmdNames = append(debugCmdNames, name) + } + sort.Strings(debugCmdNames) + + // Set command aliases. + debugCmds["b"] = debugCmds["break"] + debugCmds["bp"] = debugCmds["breakpoints"] + debugCmds["bt"] = debugCmds["stack"] + debugCmds["c"] = debugCmds["continue"] + debugCmds["h"] = debugCmds["help"] + debugCmds["l"] = debugCmds["list"] + debugCmds["p"] = debugCmds["print"] + debugCmds["quit"] = debugCmds["exit"] + debugCmds["q"] = debugCmds["exit"] + debugCmds["s"] = debugCmds["step"] + debugCmds["si"] = debugCmds["stepi"] +} + +// Debug is the debug callback invoked at each VM execution step. It implements the DebugState FSA. +func (m *Machine) Debug() { +loop: + for { + switch m.Debugger.state { + case DebugAtInit: + debugUpdateLocation(m) + fmt.Fprintln(m.Debugger.out, "Welcome to the Gnovm debugger. Type 'help' for list of commands.") + m.Debugger.scanner = bufio.NewScanner(m.Debugger.in) + m.Debugger.state = DebugAtCmd + case DebugAtCmd: + if err := debugCmd(m); err != nil { + fmt.Fprintln(m.Debugger.out, "Command failed:", err) + } + case DebugAtRun: + switch m.Debugger.lastCmd { + case "si", "stepi": + m.Debugger.state = DebugAtCmd + debugLineInfo(m) + case "s", "step": + if m.Debugger.loc != m.Debugger.prevLoc && m.Debugger.loc.File != "" { + m.Debugger.state = DebugAtCmd + m.Debugger.prevLoc = m.Debugger.loc + debugList(m, "") + continue loop + } + default: + for _, b := range m.Debugger.breakpoints { + if b == m.Debugger.loc && m.Debugger.loc != m.Debugger.prevLoc { + m.Debugger.state = DebugAtCmd + m.Debugger.prevLoc = m.Debugger.loc + debugList(m, "") + continue loop + } + } + } + break loop + case DebugAtExit: + os.Exit(0) + } + } + debugUpdateLocation(m) + + // Keep track of exact locations when performing calls. + op := m.Ops[m.NumOps-1] + switch op { + case OpCall: + m.Debugger.call = append(m.Debugger.call, m.Debugger.loc) + case OpReturn, OpReturnFromBlock: + m.Debugger.call = m.Debugger.call[:len(m.Debugger.call)-1] + } +} + +// debugCmd processes a debugger REPL command. It displays a prompt, then +// reads and parses a command from the debugger input stream, then executes +// the corresponding function or returns an error. +// If the command is empty, the last non-empty command is repeated. +func debugCmd(m *Machine) error { + var cmd, arg string + fmt.Fprint(m.Debugger.out, "dbg> ") + if !m.Debugger.scanner.Scan() { + return debugDetach(m, arg) // Clean close of debugger, the target program resumes. + } + line := trimLeftSpace(m.Debugger.scanner.Text()) + if i := indexSpace(line); i >= 0 { + cmd = line[:i] + arg = trimLeftSpace(line[i:]) + } else { + cmd = line + } + if cmd == "" { + if m.Debugger.lastCmd == "" { + return nil + } + cmd, arg = m.Debugger.lastCmd, m.Debugger.lastArg + } else if cmd[0] == '#' { + return nil + } + c, ok := debugCmds[cmd] + if !ok { + return errors.New("command not available: " + cmd) + } + m.Debugger.lastCmd, m.Debugger.lastArg = cmd, arg + return c.debugFunc(m, arg) +} + +func trimLeftSpace(s string) string { return strings.TrimLeftFunc(s, unicode.IsSpace) } +func indexSpace(s string) int { return strings.IndexFunc(s, unicode.IsSpace) } + +// Serve waits for a remote client to connect to addr and use this connection for debugger IO. +// It returns an error if the connection can not be established, or nil. +func (d *Debugger) Serve(addr string) error { + l, err := net.Listen("tcp", addr) + if err != nil { + return err + } + print("Waiting for debugger client to connect at ", addr) + conn, err := l.Accept() + if err != nil { + return err + } + println(" connected!") + d.in, d.out = conn, conn + return nil +} + +// debugUpdateLocation computes the source code location for the current VM state. +// The result is stored in Debugger.DebugLoc. +func debugUpdateLocation(m *Machine) { + loc := m.LastBlock().Source.GetLocation() + + if m.Debugger.loc.PkgPath == "" || + loc.PkgPath != "" && loc.PkgPath != m.Debugger.loc.PkgPath || + loc.File != "" && loc.File != m.Debugger.loc.File { + m.Debugger.loc = loc + } + + // The location computed from above points to the block start. Examine + // expressions and statements to have the exact line within the block. + + nx := len(m.Exprs) + for i := nx - 1; i >= 0; i-- { + expr := m.Exprs[i] + if l := expr.GetLine(); l > 0 { + m.Debugger.loc.Line = l + return + } + } + + if len(m.Stmts) > 0 { + if stmt := m.PeekStmt1(); stmt != nil { + if l := stmt.GetLine(); l > 0 { + m.Debugger.loc.Line = l + return + } + } + } +} + +// --------------------------------------- +const ( + breakUsage = `break|b [locspec]` + breakShort = `Set a breakpoint.` + breakLong = ` +The syntax accepted for locspec is: +- : specifies the line in filename. Filename can be relative. +- specifies the line in the current source file. +- + specifies the line offset lines after the current one. +- - specifies the line offset lines before the current one. +` +) + +func debugBreak(m *Machine, arg string) error { + loc, err := parseLocSpec(m, arg) + if err != nil { + return err + } + m.Debugger.breakpoints = append(m.Debugger.breakpoints, loc) + printBreakpoint(m, len(m.Debugger.breakpoints)-1) + return nil +} + +func printBreakpoint(m *Machine, i int) { + b := m.Debugger.breakpoints[i] + fmt.Fprintf(m.Debugger.out, "Breakpoint %d at %s %s\n", i, b.PkgPath, b) +} + +func parseLocSpec(m *Machine, arg string) (loc Location, err error) { + var line int + loc = m.Debugger.loc + + if strings.Contains(arg, ":") { + // Location is specified by filename:line. + strs := strings.Split(arg, ":") + if strs[0] != "" { + if loc.File, err = filepath.Abs(strs[0]); err != nil { + return loc, err + } + loc.File = filepath.Clean(loc.File) + } + if line, err = strconv.Atoi(strs[1]); err != nil { + return loc, err + } + loc.Line = line + return loc, nil + } + // Location is in the current file. + if loc.File == "" { + return loc, errors.New("unknown source file") + } + if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") { + // Location is specified as a line offset from the current line. + if line, err = strconv.Atoi(arg); err != nil { + return loc, err + } + loc.Line += line + return loc, nil + } + if line, err = strconv.Atoi(arg); err == nil { + // Location is the line number in the current file. + loc.Line = line + return loc, nil + } + return loc, err +} + +// --------------------------------------- +const ( + breakpointsUsage = `breakpoints|bp` + breakpointsShort = `Print out info for active breakpoints.` +) + +func debugBreakpoints(m *Machine, arg string) error { + for i := range m.Debugger.breakpoints { + printBreakpoint(m, i) + } + return nil +} + +// --------------------------------------- +const ( + clearUsage = `clear [id]` + clearShort = `Delete breakpoint (all if no id).` +) + +func debugClear(m *Machine, arg string) error { + if arg != "" { + id, err := strconv.Atoi(arg) + if err != nil || id < 0 || id >= len(m.Debugger.breakpoints) { + return fmt.Errorf("invalid breakpoint id: %v", arg) + } + m.Debugger.breakpoints = append(m.Debugger.breakpoints[:id], m.Debugger.breakpoints[id+1:]...) + return nil + } + m.Debugger.breakpoints = nil + return nil +} + +// --------------------------------------- +const ( + continueUsage = `continue|c` + continueShort = `Run until breakpoint or program termination.` +) + +const ( + stepUsage = `step|s` + stepShort = `Single step through program.` +) + +const ( + stepiUsage = `stepi|si` + stepiShort = `Single step a single VM instruction.` +) + +func debugContinue(m *Machine, arg string) error { + m.Debugger.state = DebugAtRun + m.Debugger.frameLevel = 0 + return nil +} + +// --------------------------------------- +const ( + detachUsage = `detach` + detachShort = `Close debugger and resume program.` +) + +func debugDetach(m *Machine, arg string) error { + m.Debugger.enabled = false + m.Debugger.state = DebugAtRun + if i, ok := m.Debugger.in.(io.Closer); ok { + i.Close() + } + return nil +} + +// --------------------------------------- +const ( + downUsage = `down [n]` + downShort = `Move the current frame down by n (default 1).` +) + +func debugDown(m *Machine, arg string) (err error) { + n := 1 + if arg != "" { + if n, err = strconv.Atoi(arg); err != nil { + return err + } + } + if level := m.Debugger.frameLevel - n; level >= 0 && level < len(m.Debugger.call) { + m.Debugger.frameLevel = level + } + debugList(m, "") + return nil +} + +// --------------------------------------- +const ( + exitUsage = `exit|quit|q` + exitShort = `Exit the debugger and program.` +) + +func debugExit(m *Machine, arg string) error { m.Debugger.state = DebugAtExit; return nil } + +// --------------------------------------- +const ( + helpUsage = `help|h [command]` + helpShort = `Print the help message.` +) + +func debugHelp(m *Machine, arg string) error { + c, ok := debugCmds[arg] + if !ok && arg != "" { + return errors.New("command not available") + } + if ok { + t := fmt.Sprintf("%-25s %s", c.usage, c.short) + if c.long != "" { + t += "\n" + c.long + } + fmt.Fprintln(m.Debugger.out, t) + return nil + } + t := "The following commands are available:\n\n" + for _, name := range debugCmdNames { + c := debugCmds[name] + t += fmt.Sprintf("%-25s %s\n", c.usage, c.short) + } + t += "\nType help followed by a command for full documentation." + fmt.Fprintln(m.Debugger.out, t) + return nil +} + +// --------------------------------------- +const ( + listUsage = `list|l [locspec]` + listShort = `Show source code.` + listLong = ` +See 'help break' for locspec syntax. If locspec is empty, +list shows the source code around the current line. +` +) + +func debugList(m *Machine, arg string) (err error) { + loc := m.Debugger.loc + hideCursor := false + + if arg == "" { + debugLineInfo(m) + if m.Debugger.lastCmd == "up" || m.Debugger.lastCmd == "down" { + loc = debugFrameLoc(m, m.Debugger.frameLevel) + fmt.Fprintf(m.Debugger.out, "Frame %d: %s\n", m.Debugger.frameLevel, loc) + } + } else { + if loc, err = parseLocSpec(m, arg); err != nil { + return err + } + hideCursor = true + fmt.Fprintf(m.Debugger.out, "Showing %s\n", loc) + } + if loc.File == "" && (m.Debugger.lastCmd == "list" || m.Debugger.lastCmd == "l") { + return errors.New("unknown source file") + } + src, err := fileContent(m.Store, loc.PkgPath, loc.File) + if err != nil { + return err + } + lines, offset := linesAround(src, loc.Line, 10) + for i, l := range lines { + cursor := "" + if !hideCursor && loc.Line == i+offset { + cursor = "=>" + } + fmt.Fprintf(m.Debugger.out, "%2s %4d: %s\n", cursor, i+offset, l) + } + return nil +} + +func debugLineInfo(m *Machine) { + if m.Debugger.loc.File == "" { + return + } + line := string(m.Package.PkgName) + if len(m.Frames) > 0 { + f := m.Frames[len(m.Frames)-1] + if f.Func != nil { + line += "." + string(f.Func.Name) + "()" + } + } + fmt.Fprintf(m.Debugger.out, "> %s %s\n", line, m.Debugger.loc) +} + +func isMemPackage(st Store, pkgPath string) bool { + ds, ok := st.(*defaultStore) + return ok && ds.iavlStore.Has([]byte(backendPackagePathKey(pkgPath))) +} + +func fileContent(st Store, pkgPath, name string) (string, error) { + if isMemPackage(st, pkgPath) { + return st.GetMemFile(pkgPath, name).Body, nil + } + buf, err := os.ReadFile(name) + return string(buf), err +} + +func linesAround(src string, index, n int) ([]string, int) { + lines := strings.Split(src, "\n") + start := max(1, index-n/2) - 1 + end := min(start+n, len(lines)) + if start >= end { + start = max(1, end-n) + } + return lines[start:end], start + 1 +} + +// --------------------------------------- +const ( + printUsage = `print|p ` + printShort = `Print a variable or expression.` +) + +func debugPrint(m *Machine, arg string) (err error) { + if arg == "" { + return errors.New("missing argument") + } + // Use the Go parser to get the AST representation of print argument as a Go expresssion. + ast, err := parser.ParseExpr(arg) + if err != nil { + return err + } + tv, err := debugEvalExpr(m, ast) + if err != nil { + return err + } + fmt.Fprintln(m.Debugger.out, tv) + return nil +} + +// debugEvalExpr evaluates a Go expression in the context of the VM and returns +// the corresponding typed value, or an error. +// The supported expression syntax is a small subset of Go expressions: +// basic literals, identifiers, selectors, index expressions, or a combination +// of those are supported, but none of function calls, arithmetic, logic or +// assign operations, type assertions of convertions. +// This is sufficient for a debugger to perform 'print (*f).S[x][y]' for example. +func debugEvalExpr(m *Machine, node ast.Node) (tv TypedValue, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("%v", r) + } + }() + + switch n := node.(type) { + case *ast.BasicLit: + switch n.Kind { + case token.INT: + i, err := strconv.ParseInt(n.Value, 0, 0) + if err != nil { + return tv, err + } + return typedInt(int(i)), nil + case token.CHAR: + r, _, _, err := strconv.UnquoteChar(n.Value[1:len(n.Value)-1], 0) + if err != nil { + return tv, err + } + return typedRune(r), nil + case token.STRING: + s, err := strconv.Unquote(n.Value) + if err != nil { + return tv, err + } + return typedString(s), nil + } + return tv, fmt.Errorf("invalid basic literal value: %s", n.Value) + case *ast.Ident: + if tv, ok := debugLookup(m, n.Name); ok { + return tv, nil + } + return tv, fmt.Errorf("could not find symbol value for %s", n.Name) + case *ast.ParenExpr: + return debugEvalExpr(m, n.X) + case *ast.StarExpr: + x, err := debugEvalExpr(m, n.X) + if err != nil { + return tv, err + } + pv, ok := x.V.(PointerValue) + if !ok { + return tv, fmt.Errorf("Not a pointer value: %v", x) + } + return pv.Deref(), nil + case *ast.SelectorExpr: + x, err := debugEvalExpr(m, n.X) + if err != nil { + return tv, err + } + if pv, ok := x.V.(*PackageValue); ok { + if i, ok := pv.Block.(*Block).Source.GetLocalIndex(Name(n.Sel.Name)); ok { + return pv.Block.(*Block).Values[i], nil + } + return tv, fmt.Errorf("invalid selector: %s", n.Sel.Name) + } + tr, _, _, _, _ := findEmbeddedFieldType(x.T.GetPkgPath(), x.T, Name(n.Sel.Name), nil) + if len(tr) == 0 { + return tv, fmt.Errorf("invalid selector: %s", n.Sel.Name) + } + for _, vp := range tr { + x = x.GetPointerTo(m.Alloc, m.Store, vp).Deref() + } + return x, nil + case *ast.IndexExpr: + x, err := debugEvalExpr(m, n.X) + if err != nil { + return tv, err + } + index, err := debugEvalExpr(m, n.Index) + if err != nil { + return tv, err + } + return x.GetPointerAtIndex(m.Alloc, m.Store, &index).Deref(), nil + default: + err = fmt.Errorf("expression not supported: %v", n) + } + return tv, err +} + +// debugLookup returns the current VM value corresponding to name ident in +// the current function call frame, or the global frame if not found. +// Note: the commands 'up' and 'down' change the frame level to start from. +func debugLookup(m *Machine, name string) (tv TypedValue, ok bool) { + // Position to the right frame. + ncall := 0 + var i int + var fblocks []BlockNode + var funBlock BlockNode + for i = len(m.Frames) - 1; i >= 0; i-- { + if m.Frames[i].Func != nil { + funBlock = m.Frames[i].Func.Source + } + if ncall == m.Debugger.frameLevel { + break + } + if m.Frames[i].Func != nil { + fblocks = append(fblocks, m.Frames[i].Func.Source) + ncall++ + } + } + if i < 0 { + return tv, false + } + + // Position to the right block, i.e the first after the last fblock (if any). + for i = len(m.Blocks) - 1; i >= 0; i-- { + if len(fblocks) == 0 { + break + } + if m.Blocks[i].Source == fblocks[0] { + fblocks = fblocks[1:] + } + } + if i < 0 { + return tv, false + } + + // get SourceBlocks in the same frame level. + var sblocks []*Block + for ; i >= 0; i-- { + sblocks = append(sblocks, m.Blocks[i]) + if m.Blocks[i].Source == funBlock { + break + } + } + if i > 0 { + sblocks = append(sblocks, m.Blocks[0]) // Add global block + } + + // Search value in current frame level blocks, or main scope. + for _, b := range sblocks { + switch t := b.Source.(type) { + case *IfStmt: + for i, s := range ifBody(m, t).Source.GetBlockNames() { + if string(s) == name { + return b.Values[i], true + } + } + } + for i, s := range b.Source.GetBlockNames() { + if string(s) == name { + return b.Values[i], true + } + } + } + // Fallback: search a global value. + if v := sblocks[0].Source.GetValueRef(m.Store, Name(name)); v != nil { + return *v, true + } + return tv, false +} + +// ifBody returns the Then or Else body corresponding to the current location. +func ifBody(m *Machine, ifStmt *IfStmt) IfCaseStmt { + if l := ifStmt.Else.GetLocation().Line; l > 0 && debugFrameLoc(m, m.Debugger.frameLevel).Line > l { + return ifStmt.Else + } + return ifStmt.Then +} + +// --------------------------------------- +const ( + stackUsage = `stack|bt` + stackShort = `Print stack trace.` +) + +func debugStack(m *Machine, arg string) error { + i := 0 + for { + ff := debugFrameFunc(m, i) + loc := debugFrameLoc(m, i) + if ff == nil { + break + } + var fname string + if ff.IsMethod { + fname = fmt.Sprintf("%v.(%v).%v", ff.PkgPath, ff.Type.(*FuncType).Params[0].Type, ff.Name) + } else { + fname = fmt.Sprintf("%v.%v", ff.PkgPath, ff.Name) + } + fmt.Fprintf(m.Debugger.out, "%d\tin %s\n\tat %s\n", i, fname, loc) + i++ + } + return nil +} + +func debugFrameFunc(m *Machine, n int) *FuncValue { + for ncall, i := 0, len(m.Frames)-1; i >= 0; i-- { + f := m.Frames[i] + if f.Func == nil { + continue + } + if ncall == n { + return f.Func + } + ncall++ + } + return nil +} + +func debugFrameLoc(m *Machine, n int) Location { + if n == 0 || len(m.Debugger.call) == 0 { + return m.Debugger.loc + } + return m.Debugger.call[len(m.Debugger.call)-n] +} + +// --------------------------------------- +const ( + upUsage = `up [n]` + upShort = `Move the current frame up by n (default 1).` +) + +func debugUp(m *Machine, arg string) (err error) { + n := 1 + if arg != "" { + if n, err = strconv.Atoi(arg); err != nil { + return err + } + } + if level := m.Debugger.frameLevel + n; level >= 0 && level < len(m.Debugger.call) { + m.Debugger.frameLevel = level + } + debugList(m, "") + return nil +} diff --git a/gnovm/pkg/gnolang/debugger_test.go b/gnovm/pkg/gnolang/debugger_test.go new file mode 100644 index 00000000000..ca50e243a5c --- /dev/null +++ b/gnovm/pkg/gnolang/debugger_test.go @@ -0,0 +1,184 @@ +package gnolang_test + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net" + "strings" + "testing" + "time" + + "github.com/gnolang/gno/gnovm/pkg/gnoenv" + "github.com/gnolang/gno/gnovm/pkg/gnolang" + "github.com/gnolang/gno/gnovm/tests" +) + +type dtest struct{ in, out string } + +const debugTarget = "../../tests/integ/debugger/sample.gno" + +type writeNopCloser struct{ io.Writer } + +func (writeNopCloser) Close() error { return nil } + +func eval(debugAddr, in, file string) (string, string, error) { + out := bytes.NewBufferString("") + err := bytes.NewBufferString("") + stdin := bytes.NewBufferString(in) + stdout := writeNopCloser{out} + stderr := writeNopCloser{err} + + testStore := tests.TestStore(gnoenv.RootDir(), "", stdin, stdout, stderr, tests.ImportModeStdlibsPreferred) + + f := gnolang.MustReadFile(file) + + m := gnolang.NewMachineWithOptions(gnolang.MachineOptions{ + PkgPath: string(f.PkgName), + Input: stdin, + Output: stdout, + Store: testStore, + Debug: true, + DebugAddr: debugAddr, + }) + + defer m.Release() + + if debugAddr != "" { + if err := m.Debugger.Serve(debugAddr); err != nil { + return "", "", err + } + } + + m.RunFiles(f) + ex, _ := gnolang.ParseExpr("main()") + m.Eval(ex) + return out.String(), err.String(), nil +} + +func runDebugTest(t *testing.T, targetPath string, tests []dtest) { + t.Helper() + + for _, test := range tests { + t.Run("", func(t *testing.T) { + out, err, _ := eval("", test.in, targetPath) + t.Log("in:", test.in, "out:", out, "err:", err) + if !strings.Contains(out, test.out) { + t.Errorf("result does not contain \"%s\", got \"%s\"", test.out, out) + } + }) + } +} + +func TestDebug(t *testing.T) { + brk := "break 7\n" + cont := brk + "continue\n" + cont2 := "break 21\ncontinue\n" + + runDebugTest(t, debugTarget, []dtest{ + {in: "", out: "Welcome to the Gnovm debugger. Type 'help' for list of commands."}, + {in: "help\n", out: "The following commands are available"}, + {in: "h\n", out: "The following commands are available"}, + {in: "help b\n", out: "Set a breakpoint."}, + {in: "help zzz\n", out: "command not available"}, + {in: "list " + debugTarget + ":1\n", out: "1: // This is a sample target"}, + {in: "l 55\n", out: "42: }"}, + {in: "l xxx:0\n", out: "xxx: no such file or directory"}, + {in: "l :xxx\n", out: `"xxx": invalid syntax`}, + {in: brk, out: "Breakpoint 0 at main "}, + {in: "break :zzz\n", out: `"zzz": invalid syntax`}, + {in: "b +xxx\n", out: `"+xxx": invalid syntax`}, + {in: cont, out: "=> 7: println(name, i)"}, + {in: cont + "stack\n", out: "2 in main.main"}, + {in: cont + "up\n", out: "=> 11: f(s, n)"}, + {in: cont + "up\nup\ndown\n", out: "=> 11: f(s, n)"}, + {in: cont + "print name\n", out: `("hello" string)`}, + {in: cont + "p i\n", out: "(3 int)"}, + {in: cont + "up\np global\n", out: `("test" string)`}, + {in: cont + "bp\n", out: "Breakpoint 0 at main "}, + {in: "p 3\n", out: "(3 int)"}, + {in: "p 'a'\n", out: "(97 int32)"}, + {in: "p '界'\n", out: "(30028 int32)"}, + {in: "p \"xxxx\"\n", out: `("xxxx" string)`}, + {in: "si\n", out: "sample.gno:4"}, + {in: "s\ns\n", out: "=> 33: num := 5"}, + {in: "s\n\n", out: "=> 33: num := 5"}, + {in: "foo", out: "command not available: foo"}, + {in: "\n\n", out: "dbg> "}, + {in: "#\n", out: "dbg> "}, + {in: "p foo", out: "Command failed: could not find symbol value for foo"}, + {in: "b +7\nc\n", out: "=> 11:"}, + {in: brk + "clear 0\n", out: "dbg> "}, + {in: brk + "clear -1\n", out: "Command failed: invalid breakpoint id: -1"}, + {in: brk + "clear\n", out: "dbg> "}, + {in: "p\n", out: "Command failed: missing argument"}, + {in: "p 1+2\n", out: "Command failed: expression not supported"}, + {in: "p 1.2\n", out: "Command failed: invalid basic literal value: 1.2"}, + {in: "p 31212324222123123232123123123123123123123123123123\n", out: "value out of range"}, + {in: "p 3)\n", out: "Command failed:"}, + {in: "p (3)", out: "(3 int)"}, + {in: cont2 + "bt\n", out: "0 in main.(*main.T).get"}, + {in: cont2 + "p t.A[2]\n", out: "(3 int)"}, + {in: cont2 + "p t.A[k]\n", out: "could not find symbol value for k"}, + {in: cont2 + "p *t\n", out: "(struct{(slice[(1 int),(2 int),(3 int)] []int)} main.T)"}, + {in: cont2 + "p *i\n", out: "Not a pointer value: (1 int)"}, + {in: cont2 + "p *a\n", out: "could not find symbol value for a"}, + {in: cont2 + "p a[1]\n", out: "could not find symbol value for a"}, + {in: cont2 + "p t.B\n", out: "invalid selector: B"}, + {in: "down xxx", out: `"xxx": invalid syntax`}, + {in: "up xxx", out: `"xxx": invalid syntax`}, + {in: "b 37\nc\np b\n", out: "(3 int)"}, + {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)"}, + }) + + runDebugTest(t, "../../tests/files/a1.gno", []dtest{ + {in: "l\n", out: "unknown source file"}, + {in: "b 5\n", out: "unknown source file"}, + }) + + runDebugTest(t, "../../tests/integ/debugger/sample2.gno", []dtest{ + {in: "s\np tests\n", out: "(package(tests gno.land/p/demo/tests) package{})"}, + {in: "s\np tests.World\n", out: `("world" string)`}, + {in: "s\np tests.xxx\n", out: "Command failed: invalid selector: xxx"}, + }) +} + +const debugAddress = "localhost:17358" + +func TestRemoteDebug(t *testing.T) { + var ( + conn net.Conn + err error + retry int + ) + + go eval(debugAddress, "", debugTarget) + + for retry = 100; retry > 0; retry-- { + conn, err = net.Dial("tcp", debugAddress) + if err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if retry == 0 { + t.Error(err) + } + defer conn.Close() + + fmt.Fprintf(conn, "d\n") + resp, err := bufio.NewReader(conn).ReadString('\n') + if err != nil { + t.Error(err) + } + t.Log("resp:", resp) +} + +func TestRemoteError(t *testing.T) { + _, _, err := eval(":xxx", "", debugTarget) + if !strings.Contains(err.Error(), "tcp/xxx: unknown port") { + t.Error(err) + } +} diff --git a/gnovm/pkg/gnolang/machine.go b/gnovm/pkg/gnolang/machine.go index 2e0fd909ed7..68eb44290e2 100644 --- a/gnovm/pkg/gnolang/machine.go +++ b/gnovm/pkg/gnolang/machine.go @@ -51,6 +51,8 @@ type Machine struct { NumResults int // number of results returned Cycles int64 // number of "cpu" cycles + Debugger Debugger + // Configuration CheckTypes bool // not yet used ReadOnly bool @@ -91,6 +93,9 @@ type MachineOptions struct { PkgPath string CheckTypes bool // not yet used ReadOnly bool + Debug bool + DebugAddr string // debugger io stream address (stdin/stdout if empty) + Input io.Reader // used for default debugger input only Output io.Writer // default os.Stdout Store Store // default NewStore(Alloc, nil, nil) Context interface{} @@ -159,6 +164,9 @@ func NewMachineWithOptions(opts MachineOptions) *Machine { mm.Store = store mm.Context = context mm.GasMeter = vmGasMeter + mm.Debugger.enabled = opts.Debug + mm.Debugger.in = opts.Input + mm.Debugger.out = output if pv != nil { mm.SetActivePackage(pv) @@ -1118,6 +1126,9 @@ const ( func (m *Machine) Run() { for { + if m.Debugger.enabled { + m.Debug() + } op := m.PopOp() // TODO: this can be optimized manually, even into tiers. switch op { diff --git a/gnovm/tests/integ/debugger/sample.gno b/gnovm/tests/integ/debugger/sample.gno new file mode 100644 index 00000000000..ecd980acf05 --- /dev/null +++ b/gnovm/tests/integ/debugger/sample.gno @@ -0,0 +1,42 @@ +// This is a sample target gno program to test the gnovm debugger. +// See ../../cmd/gno/debug_test.go for the debugger test cases. + +package main + +func f(name string, i int) { + println(name, i) +} + +func g(s string, n int) { + f(s, n) +} + +var global = "test" + +type T struct { + A []int +} + +func (t *T) get(i int) int { + r := t.A[i] + if i == 0 { + b := "zero" + println(b) + } else { + b := "!zero" + println(b) + } + return r +} + +func main() { + num := 5 + println("in main") + if num > 2 { + b := 3 + g("hello", b) + } + t := T{A: []int{1, 2, 3} } + println(t.get(1)) + println("bye") +} diff --git a/gnovm/tests/integ/debugger/sample2.gno b/gnovm/tests/integ/debugger/sample2.gno new file mode 100644 index 00000000000..d2887cc8ee5 --- /dev/null +++ b/gnovm/tests/integ/debugger/sample2.gno @@ -0,0 +1,8 @@ +package main + +import "gno.land/p/demo/tests" + +func main() { + b := tests.World + println("b:", b) +}