goja icon indicating copy to clipboard operation
goja copied to clipboard

Goja debugger

Open mostafa opened this issue 3 years ago • 26 comments

Hi,

I just wanted to inform the community that I am working on a POC of a debugger for Goja and I made some progress. While talking with @MStoykov from the k6 team, he suggested that I inform the community and especially @dop251, in case there are any suggestions, recommendations, or early feedback. I'll make a PR soon, but in the meantime, this is a preview:

preview

These are what I worked on so far:

  1. A simple debugger statement implementation that just increments vm.pc and enables debugMode if runtime.EnableDebugMode() function is called in advance to enable compiling and emitting debugger commands. It involved modifications to compiler, runtime and vm structs and might be backward-incompatible.
  2. A simple REPL that access various commands, of which only next, continue and run are implemented. There are some bugs (and caveats) to be fixed before the initial PR. The REPL has abbreviated commands (n, c and r) and remembers the last commands that was used, so that the next Enter would end up executing the last command. The next command currently acts as a step command somehow until I figured out how to jump lines. The continue command work as expected, continuing execution until the next debugger command. The REPL mimics the behavior of node inspect.
  3. The run command in REPL evaluates and executes any function or variable up to the point of the current stack and program counter in the local context. The process starts by parsing the line, compiling it and then inserting the compiled instructions after the current program counter at the vm.prg.code and then executing the line. Before any insertions, the context and the state is saved, and after the execution, both will be restored.
  4. The stdout is captured every single time a command runs and released after it.

I'd be happy to hear your feedback, although I haven't made a PR yet.

UPDATE: This is a terminal recording from the latest changes and updates on 1 June 2021: https://asciinema.org/a/423306

Disclaimer: I work at @k6io, but this is voluntary work I do in my free time.

mostafa avatar Jun 15 '21 09:06 mostafa

This looks really cool! The first two questions that came to mind for me was whether the debugger would support source maps (and potentially from non-JS sources, e.g. TS), and whether it would be fully controllable programmatically via an API. For the latter I'm thinking of cases where goja is embedded in a larger application where debugging the running program via a CLI application might not be possible.

nwidger avatar Jun 18 '21 10:06 nwidger

@nwidger

whether the debugger would support source maps (and potentially from non-JS sources, e.g. TS)

Yesterday I experimented a bit with File.sourceMap and WithSourceMapLoader for detecting the correct line number based on the current program counter (vm.pc), but I ended up using SetLinesForContent function from Go token library. But the argument is valid and source-maps should be taken into account.

whether it would be fully controllable programmatically via an API

I also thought about having a websocket server to control the debugger remotely, but I think it's a bit too early, but is still a topic for discussion.

mostafa avatar Jun 18 '21 18:06 mostafa

The changes are on the debugger branch, which contains:

  1. A REPL event-loop. These commands currently work with some caveats listed below:

    • next, n: Executes the next line
    • cont, c: Continue execution until the next debugger statement
    • exec, e: Executes code (in the global context :disappointed:, currently)
    • print, p: Prints the value of the variable in the global scope, otherwise the current value stack (good for inspection of current object values)
    • list, l: Prints source code, with an indicator, >, indicating the current highlighted line
    • help, h: :book:
    • quit, q: Exits the application
    • \n (enter/new line): Executes the previous command, if available
  2. A new vm.run function (vm.runDebug and here).

  3. The debugger statement emission in compiler and its execution in the VM.

  4. A flag to enable debugMode that can be set via runtime.EnableDebugMode() after runtime creation. This effectively enables debugMode on the runtime, ~compiler and the VM.~ The compiler always emits the debugger statement, which basically updates vm.pc when encountered by the VM. If the runtime.debugMode is set using runtime.EnableDebugMode(), the debugger statement will pause execution and returns a REPL. This behavior is going to be replaced by an API.

  5. Various utility functions used by the REPL and the vm.runDebug function.

  6. ~Changes to compile method, which needed subsequent changes to goja_nodejs.~

Caveats:

  1. The next command is buggy, because the vm.prg.src.Position(vm.prg.sourceOffset(vm.pc)).Line reports incorrect lines and hence the vm.getCurrentLine() reports incorrect lines (somehow it skips some lines :man_shrugging:). I tried using the SetLinesForContent function in Go standard library, but there were other issues. The vm.getCurrentLine() is heavily relied on by various functions, so this issue causes other functions to also report and operate on the current line incorrectly. NOTE: This was apparently related to an old sourcemap being loaded for an updated script. I'll try to investigate this more, since this is/should be the only single source of truth for debugging lines.
  2. ~The exec command somehow acts as next on the next run, probably because of tainting the vm.pc.~ Fixed by https://github.com/mostafa/goja/commit/98303bf7ea9513c7bfdd36ea2f6e676f0ef2ca47
  3. ~The print command currently prints the whole value stack data, which is undesirable. Given a parameter (variable name), it should print its value.~ Now, the print command prints the value of a given variable in the global context/scope, otherwise it'll print the values stack. Implemented in https://github.com/mostafa/goja/commit/d2c2bae8a780593c47a1c57396fab313c42f6041
  4. The current implementation is probably a first try to write a debugger and shouldn't be considered complete or the ultimate solution.
  5. I also tested it on k6. It's very buggy and still needs more work on the k6 side.

mostafa avatar Jun 21 '21 08:06 mostafa

I might be misinterpreting things, but if vm.prg.src.Position(vm.prg.sourceOffset(vm.pc)).Line reports incorrect line numbers and this can be reproduced outside of your debugger branch, I think that might be something @dop251 would want to fix.

nwidger avatar Jun 22 '21 12:06 nwidger

@nwidger Apparently an old version of the sourcemap of my script was causing trouble. I updated it with the latest source code and now it works as expected.

Update: Well, partially! :disappointed:

mostafa avatar Jun 23 '21 13:06 mostafa

For reference:

  • https://github.com/mostafa/goja
  • https://github.com/mostafa/goja_nodejs
  • ~https://github.com/mostafa/goja_debugger~ No longer important, since the changes to the compiler function are reverted.

mostafa avatar Jun 23 '21 21:06 mostafa

Current supported commands:

setBreakpoint, sb       Set a breakpoint on a given file and line
clearBreakpoint, cb     Clear a breakpoint on a given file and line
breakpoints             List all known breakpoints
next, n                 Continue to next line in current file
cont, c                 Resume execution until next debugger line
exec, e                 Evaluate the expression and print the value
list, l                 Print the source around the current line where execution is currently paused
print, p                Print the provided variable's value
help, h                 Print this very help message
quit, q                 Exit debugger and quit (Ctrl+C)

mostafa avatar Jun 25 '21 20:06 mostafa

@mostafa I just played around with the demo in the goja_debugger repo and it works quite nicely! The only basic functionality that I think might be missing would be something to print the current callstack (i.e. the backtrace/bt command in gdb), and a way to get the values of all local variables in the current frame. I don't know how difficult those might be to add.

I do wonder if in the long run it might not be a bad idea for goja to export an API to drive a runtime with debug mode enabled. Perhaps something like this would be a start:

func (r *Runtime) EnableDebugMode() *Debugger {}

type Debugger struct{}

func (d *Debugger) Wait() *Break {}

func (d *Debugger) SetBreakpoint(file string, line int) error   {}
func (d *Debugger) ClearBreakpoint(file string, line int) error {}
func (d *Debugger) Breakpoints() ([]Breakpoint, error)          {}

func (d *Debugger) Next() error     {}
func (d *Debugger) Continue() error {}
func (d *Debugger) StepIn() error   {}
func (d *Debugger) StepOut() error  {}
func (d *Debugger) Quit() error     {}

func (d *Debugger) Eval(expr string) (Value, error)   {}
func (d *Debugger) Print(expr string) (string, error) {}

type Break struct{}

func (b *Break) Filename() string                         {}
func (b *Break) Line() int                                {}
func (b *Break) Source() string                           {}
func (b *Break) LocalVariables() ([]LocalVariable, error) {}
func (b *Break) CallStack() ([]StackFrame, error)         {}

with a CLI debugger using the API maybe looking something like:

	vm := goja.New()
	d := vm.EnableDebugMode()

	go func() {
		var prev *Break
		for b := d.Wait(); b != nil; b, prev = d.Wait(), b {
			if b != prev {
				fmt.Printf("Break at %s:%d", b.Filename(), b.Line())
			}
			fmt.Println("> ")
			cmd := parseCmdFromStdin()
			switch cmd.Name {
			case "cont", "c":
				d.Continue()
			case "next", "n":
				d.Next()
			case "list", "l":
				fmt.Println(b.Source())
			}
		}
	}()

	_, err := vm.RunScript(filename, string(content))
	if err != nil {
		log.Fatal(err)
	}

The advantage I see to getting a public interface like this into main goja repo would be to allow a CLI frontend, a DAP frontend, a GUI frontend, a frontend driven by some sort of REST API, etc. to all be developed separately. The development of these frontends could occur outside of the main goja repo, allowing fast development without needing to bring the goja maintainer into the picture with PR's that ask him to sign up for the potential maintenance of the frontend code for years to come. An interface like this might also be more useful for people embedding goja into a larger application where debugging the running JavaScript via a CLI frontend that expects to get commands from stdin might prove difficult to use.

Anyways, these are all Saturday morning post-too-much-coffee thoughts, and I realize you probably have your own goals and priorities with this work so please feel free to ignore me if they don't line up with your own. :D

nwidger avatar Jun 26 '21 14:06 nwidger

@nwidger,

Thanks for the inspiration. I actually like your idea and the fact that I refactor the debugger with the command pattern yesterday was a step forward for implementing the API you just mentioned, to have a good separation of concerns.

For the backtrace, I'm actually investigating it and I drafted some code that doesn't work as I want right now, but eventually it might. 😄

mostafa avatar Jun 26 '21 19:06 mostafa

@nwidger,

I implemented and exposed a new API (https://github.com/mostafa/goja/commit/f15bde19d4032fa079d43022be49068d836707af) you proposed, yet it needs more changes to make it work, that is the new event-loop, possibly based on Go channels. I also reverted the changes to the compile API (https://github.com/mostafa/goja/commit/0ebe3efcb4b021868459c783a9b94f0c9eecdf0e and https://github.com/mostafa/goja/commit/83f1d3245cbc41796a59808c967efc9f92c1313a), so the changes to goja_nodejs is no longer relevant and the original project can be used.

mostafa avatar Jun 27 '21 15:06 mostafa

@mostafa Nice ! It looks like you're close to being able to rip the CLI frontend code out into a separate repo, if that's what you're eventually shooting for.

I think removing the changes to the compile API was a good idea. If you don't want debugger statements to do anything, don't call EnableDebugMode on the runtime, and a breaking change on the Compile methods was probably a no-no anyways.

I left a couple comments on a few of the Debugger methods, please let me know if what I said didn't make sense.

nwidger avatar Jun 27 '21 15:06 nwidger

@nwidger Thanks! That's actually what I am trying to achieve.

Also thanks for the comments, they make sense now that I am separating CLI frontend from Goja.

mostafa avatar Jun 27 '21 16:06 mostafa

Current progress

  1. Added breakpoints and the ability of the debugger to stop at breakpoints. Actions are: set, clean and list.
  2. Implemented a command pattern and exposed an API based on those commands (suggested by @nwidger :pray:).
  3. Reverted changes to the Goja APIs (in compiler.go and others) and now the debug command only relies on the debugger being attached, otherwise only the vm.pc is incremented when the VM reaches it (which has no effect). This helped get rid of changes to goja_nodejs.
  4. Refactored most of the function for a more uniform API, e.g. removed prints and added Result record that contains Value and Err.

WIP

I extracted REPL and some commands (help and quit) into goja_debugger project. The goja_debugger would eventually become the CLI frontend for Goja's internal debugger. And I am working on the internal changes to the Goja debugger to be able to expose a better API for breakpoints, waits and other commands.

mostafa avatar Jun 28 '21 08:06 mostafa

The latest code in my fork of Goja includes an API that goja_debugger taps into to control execution using an stand-alone Debugger in Goja. So, any frontend (CLI, DAP, etc.) is now able to control the stand-alone Debugger in Goja. The example code for a CLI debugger (REPL) is the goja_debugger project.

Feel free to test it and report any issues. Your feedback is highly appreciated.

mostafa avatar Jun 29 '21 20:06 mostafa

It's been really fun watching you and @MStoykov poking away at this. It looks great, I just noticed a few small things that I wasn't sure about:

  • Does it make sense to export NewDebugger when it takes an unexported *vm?
  • Seems like no one is using Debugger.IsInsideFunc or Debugger.IsBreakOnStart. I'm not sure if these are planned to be used latter, should be deleted or just unexported.
  • Nit: A more Go-ish name for a getter would be Debugger.PC rather than Debugger.GetPC.

I'll let you know if I think of anything else. Nice work!

nwidger avatar Jun 29 '21 20:06 nwidger

@nwidger Thanks for the feedback!

  • I unexported it as newDebugger for now. It was originally exported, so it can be used to tap into the debugger, but then @MStoykov introduced WaitToActivate that does the job.
  • Left-overs from my code, so removed.
  • Renamed GetPC to PC.

mostafa avatar Jun 30 '21 17:06 mostafa

@mostafa No problem! I just noticed one minor issue, if you don't include the inspect command to goja_debugger, you get a panic:

$ ./goja_debugger test.js      
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x40 pc=0x66df8e]

goroutine 6 [running]:
github.com/dop251/goja.(*Debugger).WaitToActivate(0x0, 0x1000, 0x1000, 0xc000200000)
	/home/niels/projects/goja/debugger.go:50 +0x4e
main.main.func5(0x7bc590, 0xc00000c780)
	/home/niels/projects/goja_debugger/main.go:108 +0x165
created by main.main
	/home/niels/projects/goja_debugger/main.go:105 +0x405
$ ./goja_debugger inspect test.js
Welcome to Goja debugger
Type 'help' or 'h' for list of commands.
Loaded sourcemap from: test.js.map
Break on start in test.js:1
debug[0]> 

nwidger avatar Jun 30 '21 17:06 nwidger

@nwidger Thanks for the heads-up. It's now fixed.

mostafa avatar Jun 30 '21 18:06 mostafa

@mostafa I think on-the-fly sourcemap generation might be possible with esbuild's Transform API:

package main

import (
	"fmt"

	"github.com/evanw/esbuild/pkg/api"
)

func main() {
	result := api.Transform("var x = 1", api.TransformOptions{
		Sourcemap: api.SourceMapInline,
	})

	if len(result.Errors) > 0 {
		fmt.Println(result.Errors)
	}

	fmt.Printf("%s", result.Code)
}

Running this prints:

var x = 1;
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsidmFyIHggPSAxIl0sCiAgIm1hcHBpbmdzIjogIkFBQUEsSUFBSSxJQUFJOyIsCiAgIm5hbWVzIjogW10KfQo=

Here're some doc links that might prove useful:

https://esbuild.github.io/api/#transform-api https://esbuild.github.io/api/#sourcemap

nwidger avatar Jun 30 '21 23:06 nwidger

@nwidger I've added on-the-fly sourcemap generation, thanks to your suggested snippet and package.

mostafa avatar Jul 01 '21 11:07 mostafa

@MStoykov added dynamic variable resolution, which results in local scoped variables to be visible to print and exec.

I added backtrace to goja_debugger and fixed panic on ReferenceError in print in goja.

mostafa avatar Jul 02 '21 08:07 mostafa

A small demo to brighten up your day on top of the POC by @MStoykov: https://asciinema.org/a/423945

mostafa avatar Jul 05 '21 13:07 mostafa

Are there things still missing before it gets merged?

faisalraja avatar Mar 26 '22 14:03 faisalraja

@faisalraja Like every other piece of code, it isn't perfect. There are some issues that need to be addressed, plus it should conform to TC39 if there are any for debugging (look at Goja debugger roadmap). The biggest challenge we had was the correct mapping of the program counter (PC) to the exact line of code. It seemed to have been fixed using sourcemaps, but some tests proved otherwise. For example, the PC is reset inside a function, so one should also account for that. The main functionality heavily relies on this seemingly simple challenge. If one can fix this, the rest of the functionality, like step-out and a few things, can be easily fixed and possibly merged. Also, I should go through the code again and see if anything is missing. I'd be happy to receive contributions by having more eyes on it.

mostafa avatar Mar 29 '22 10:03 mostafa

Is this exposed in k6 ?

I raised this which is support for js and wasm in k6. https://github.com/grafana/k6/issues/2562

gedw99 avatar Jun 11 '22 16:06 gedw99

@gedw99 Please have a look at this POC: https://github.com/grafana/k6/tree/feature/PoCDebugger And this frontend: https://github.com/mostafa/goja_debugger

mostafa avatar Jun 11 '22 18:06 mostafa