Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Goja debugger #294

Closed
mostafa opened this issue Jun 15, 2021 · 27 comments
Closed

Goja debugger #294

mostafa opened this issue Jun 15, 2021 · 27 comments

Comments

@mostafa
Copy link

mostafa commented Jun 15, 2021

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.

@nwidger
Copy link
Contributor

nwidger commented Jun 18, 2021

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.

@mostafa
Copy link
Author

mostafa commented Jun 18, 2021

@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
Copy link
Author

mostafa commented Jun 21, 2021

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 😞, 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: 📖
    • 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 🤷‍♂️). 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 mostafa@98303bf
  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 mostafa@d2c2bae
  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.

@nwidger
Copy link
Contributor

nwidger commented Jun 22, 2021

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.

@mostafa
Copy link
Author

mostafa commented Jun 23, 2021

@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! 😞

@mostafa
Copy link
Author

mostafa commented Jun 23, 2021

For reference:

@mostafa
Copy link
Author

mostafa commented Jun 25, 2021

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)

@nwidger
Copy link
Contributor

nwidger commented Jun 26, 2021

@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

@mostafa
Copy link
Author

mostafa commented Jun 26, 2021

@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
Copy link
Author

mostafa commented Jun 27, 2021

@nwidger,

I implemented and exposed a new API (mostafa@f15bde1) 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 (mostafa@0ebe3ef and mostafa@83f1d32), so the changes to goja_nodejs is no longer relevant and the original project can be used.

@nwidger
Copy link
Contributor

nwidger commented Jun 27, 2021

@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.

@mostafa
Copy link
Author

mostafa commented Jun 27, 2021

@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
Copy link
Author

mostafa commented Jun 28, 2021

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 🙏).
  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
Copy link
Author

mostafa commented Jun 29, 2021

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.

@nwidger
Copy link
Contributor

nwidger commented Jun 29, 2021

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!

@mostafa
Copy link
Author

mostafa commented Jun 30, 2021

@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.

@nwidger
Copy link
Contributor

nwidger commented Jun 30, 2021

@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]> 

@mostafa
Copy link
Author

mostafa commented Jun 30, 2021

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

@nwidger
Copy link
Contributor

nwidger commented Jun 30, 2021

@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

@mostafa
Copy link
Author

mostafa commented Jul 1, 2021

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

@mostafa mostafa mentioned this issue Jul 1, 2021
@mostafa
Copy link
Author

mostafa commented Jul 2, 2021

@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
Copy link
Author

mostafa commented Jul 5, 2021

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

@faisalraja
Copy link

Are there things still missing before it gets merged?

@mostafa
Copy link
Author

mostafa commented Mar 29, 2022

@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.

@gedw99
Copy link

gedw99 commented Jun 11, 2022

Is this exposed in k6 ?

I raised this which is support for js and wasm in k6. grafana/k6#2562

@mostafa
Copy link
Author

mostafa commented Jun 11, 2022

@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
Copy link
Author

mostafa commented Jan 20, 2023

This seems stale, so let's close it until we have a consensus and time to do it.

@mostafa mostafa closed this as completed Jan 20, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants