From 8fbe60ece40af7f054ee64916ea0047866e77c30 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Sun, 22 Feb 2026 22:53:54 +1000 Subject: [PATCH 1/5] feat: add debugger support for goja runtime --- compiler.go | 17 +- compiler_expr.go | 24 +- compiler_stmt.go | 17 + debugger.go | 860 +++++++++++++++ debugger/README.md | 404 +++++++ debugger/go.mod | 17 + debugger/go.sum | 12 + debugger/handlers.go | 512 +++++++++ debugger/refs.go | 58 + debugger/server.go | 454 ++++++++ debugger/server_test.go | 994 ++++++++++++++++++ debugger/vscode-goja-debugger/.vscodeignore | 2 + debugger/vscode-goja-debugger/extension.js | 173 +++ debugger/vscode-goja-debugger/package.json | 103 ++ .../vscode-goja-debugger-0.0.3.vsix | Bin 0 -> 3950 bytes debugger_test.go | 835 +++++++++++++++ runtime.go | 55 +- vm.go | 44 + vm_debug.go | 222 ++++ 19 files changed, 4795 insertions(+), 8 deletions(-) create mode 100644 debugger.go create mode 100644 debugger/README.md create mode 100644 debugger/go.mod create mode 100644 debugger/go.sum create mode 100644 debugger/handlers.go create mode 100644 debugger/refs.go create mode 100644 debugger/server.go create mode 100644 debugger/server_test.go create mode 100644 debugger/vscode-goja-debugger/.vscodeignore create mode 100644 debugger/vscode-goja-debugger/extension.js create mode 100644 debugger/vscode-goja-debugger/package.json create mode 100644 debugger/vscode-goja-debugger/vscode-goja-debugger-0.0.3.vsix create mode 100644 debugger_test.go create mode 100644 vm_debug.go diff --git a/compiler.go b/compiler.go index 8ef842534..503a27ae8 100644 --- a/compiler.go +++ b/compiler.go @@ -2,12 +2,13 @@ package goja import ( "fmt" - "github.com/dop251/goja/token" "sort" "github.com/dop251/goja/ast" "github.com/dop251/goja/file" + "github.com/dop251/goja/token" "github.com/dop251/goja/unistring" + "github.com/go-sourcemap/sourcemap" ) type blockType int @@ -89,6 +90,8 @@ type compiler struct { codeScratchpad []instruction stringCache map[unistring.String]Value + + debugMode bool // when true, emit debug variable maps for the debugger } type binding struct { @@ -327,6 +330,7 @@ func (c *compiler) leaveScopeBlock(enter *enterBlock) { leave := &leaveBlock{ stackSize: enter.stackSize, popStash: enter.stashSize > 0, + dbgPop: len(enter.dbgNames) > 0, } c.emit(leave) for _, pc := range c.block.breaks { @@ -467,6 +471,17 @@ func (p *Program) sourceOffset(pc int) int { return 0 } +// SetSourceMap attaches a source map to the program. Once set, all position +// resolution (including debugger breakpoint matching and stack traces) +// automatically maps through the source map to original source positions. +// This is useful when the source was transpiled (e.g., TypeScript to JavaScript) +// and you want debugging to work against the original source. +func (p *Program) SetSourceMap(m *sourcemap.Consumer) { + if p.src != nil { + p.src.SetSourceMap(m) + } +} + func (p *Program) addSrcMap(srcPos int) { if len(p.srcMap) > 0 && p.srcMap[len(p.srcMap)-1].srcPos == srcPos { return diff --git a/compiler_expr.go b/compiler_expr.go index 3f537415d..10a9e4a35 100644 --- a/compiler_expr.go +++ b/compiler_expr.go @@ -1762,10 +1762,32 @@ func (e *compiledFunctionLiteral) compile() (prg *Program, name unistring.String e.c.p.code[emitArgsRestMark] = createArgsRestStash } } else { - enter = &enterFuncStashless{ + efl := &enterFuncStashless{ stackSize: uint32(stackSize), args: uint32(paramsCount), } + // Populate dbgNames so the debugger can see stack-register variables. + if e.c.debugMode && (stackSize > 0 || paramsCount > 0) { + localIdx := 0 + for i, b := range s.bindings { + if b.name == thisBindingName { + continue + } + if i < int(s.numArgs) { + if efl.dbgNames == nil { + efl.dbgNames = make(map[unistring.String]int) + } + efl.dbgNames[b.name] = -(i + 1) + } else { + if efl.dbgNames == nil { + efl.dbgNames = make(map[unistring.String]int) + } + efl.dbgNames[b.name] = localIdx + localIdx++ + } + } + } + enter = efl if enterFunc2Mark != -1 { ef2 := &enterFuncBody{ extensible: e.c.scope.dynamic, diff --git a/compiler_stmt.go b/compiler_stmt.go index 0c09b07ba..7ed3dcc3b 100644 --- a/compiler_stmt.go +++ b/compiler_stmt.go @@ -52,6 +52,8 @@ func (c *compiler) compileStatement(v ast.Statement, needResult bool) { case *ast.WithStatement: c.compileWithStatement(v, needResult) case *ast.DebuggerStatement: + c.addSrcMap(v) + c.emit(debuggerInstr{}) default: c.assert(false, int(v.Idx0())-1, "Unknown statement type: %T", v) panic("unreachable") @@ -100,6 +102,21 @@ func (c *compiler) updateEnterBlock(enter *enterBlock) { } } enter.stashSize, enter.stackSize = uint32(stashSize), uint32(stackSize) + + // Build debug names map for stack-register variables so the debugger + // can enumerate and eval let/const variables that aren't in stash. + if c.debugMode && stackSize > 0 && !scope.dynLookup { + idx := 0 + for _, b := range scope.bindings { + if !b.inStash { + if enter.dbgNames == nil { + enter.dbgNames = make(map[unistring.String]int, stackSize) + } + enter.dbgNames[b.name] = idx + idx++ + } + } + } } func (c *compiler) compileTryStatement(v *ast.TryStatement, needResult bool) { diff --git a/debugger.go b/debugger.go new file mode 100644 index 000000000..641666794 --- /dev/null +++ b/debugger.go @@ -0,0 +1,860 @@ +package goja + +import ( + "fmt" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/dop251/goja/unistring" +) + +// DebugAction tells the VM what to do after a debug hook returns. +type DebugAction int + +const ( + // DebugContinue resumes normal execution. + DebugContinue DebugAction = iota + // DebugStepOver executes the next statement at the same or shallower call depth. + DebugStepOver + // DebugStepIn executes the next statement, stepping into function calls. + DebugStepIn + // DebugStepOut resumes until the current function returns. + DebugStepOut +) + +// DebugEvent describes why the debugger paused. +type DebugEvent int + +const ( + // DebugEventBreakpoint indicates execution hit a breakpoint. + DebugEventBreakpoint DebugEvent = iota + // DebugEventStep indicates a step operation completed. + DebugEventStep + // DebugEventPause indicates a user-requested pause. + DebugEventPause + // DebugEventDebuggerStmt indicates a `debugger` statement was hit. + DebugEventDebuggerStmt + // DebugEventEntry indicates the program entry point (stopOnEntry). + DebugEventEntry + // DebugEventException indicates an exception was thrown. + DebugEventException +) + +// DebugPosition represents a resolved source location. +type DebugPosition struct { + Filename string + Line int + Column int +} + +// DebugVariable represents a variable visible in a scope. +type DebugVariable struct { + Name string + Value Value +} + +// DebugScope represents a variable scope for inspection. +type DebugScope struct { + Type string // "local", "closure", "block", "global", "with" + Name string // Human-readable scope name + Variables []DebugVariable +} + +// Breakpoint represents a breakpoint set in a source file. +type Breakpoint struct { + ID int + Filename string // Canonical path + Line int + Column int // 0 means any column + Condition string // JS expression; pause only when truthy + HitCondition string // e.g., ">5", "==3", "10" (every 10th hit) + LogMessage string // If set, log instead of pausing + hitCount int // internal counter +} + +// BreakpointOption configures optional breakpoint properties. +type BreakpointOption func(*Breakpoint) + +// WithCondition sets a conditional expression on a breakpoint. +// The breakpoint only fires when the expression evaluates to a truthy value. +func WithCondition(expr string) BreakpointOption { + return func(bp *Breakpoint) { + bp.Condition = expr + } +} + +// WithHitCondition sets a hit count condition on a breakpoint. +// Supported formats: "N" (every Nth hit), ">N", ">=N", " Breakpoint + nextBpID int //nolint:unused + bpIndex map[string]map[int][]*Breakpoint // canonical filename -> line -> breakpoints + bpByBase map[string]string // basename -> canonical path (for cross-resolution) + bpCount int32 // atomic; fast-path: skip map lookup when 0 + + // Exception breakpoints — protected by bpMu + exFilterAll bool // pause on all thrown exceptions + exFilterUncaught bool // pause only on uncaught exceptions + + // Per-call-frame debug state — VM-goroutine owned. + // Parallel to vm.callStack; pushed in saveCtx, popped in restoreCtx. + frames []debugFrame + + // Stepping state — VM-goroutine owned (only accessed during execution) + stepAction DebugAction + stepDepth int + lastSrcOffset int + lastLine int + lastPrg *Program + + // Exception state — VM-goroutine owned + currentException *Exception // set during exception breakpoint hook + lastException *Exception // dedup: prevent double-fire during propagation + + // Cross-goroutine pause request + pauseRequested uint32 // atomic +} + +// NewDebugger creates a new Debugger with the given hook function. +// The hook is called on the VM goroutine at breakpoints, step completions, +// debugger statements, and pause requests. +func NewDebugger(hook DebugHookFunc) *Debugger { + return &Debugger{ + hook: hook, + breakpoints: make(map[int]*Breakpoint), + bpIndex: make(map[string]map[int][]*Breakpoint), + bpByBase: make(map[string]string), + } +} + +// SetLogHook sets the callback for log point messages. +// Called on the VM goroutine when a log point fires. +func (d *Debugger) SetLogHook(fn DebugLogFunc) { + d.logHook = fn +} + +// SetExceptionBreakpoints configures which exceptions should pause execution. +// Supported filter values: "all" (all exceptions), "uncaught" (uncaught only). +// Safe to call from any goroutine. +func (d *Debugger) SetExceptionBreakpoints(filters []string) { + d.bpMu.Lock() + defer d.bpMu.Unlock() + + d.exFilterAll = false + d.exFilterUncaught = false + for _, f := range filters { + switch f { + case "all": + d.exFilterAll = true + case "uncaught": + d.exFilterUncaught = true + } + } +} + +// HasExceptionBreakpoints returns true if any exception filter is active. +func (d *Debugger) HasExceptionBreakpoints() bool { + d.bpMu.RLock() + defer d.bpMu.RUnlock() + return d.exFilterAll || d.exFilterUncaught +} + +// SetBreakpoint adds a breakpoint at the given source location. +// The filename is canonicalized for consistent matching. +// Column 0 means any column on that line. +// Safe to call from any goroutine. +func (d *Debugger) SetBreakpoint(filename string, line, column int, opts ...BreakpointOption) *Breakpoint { + canonical := canonicalizePath(filename) + + d.bpMu.Lock() + defer d.bpMu.Unlock() + + d.nextBpID++ + bp := &Breakpoint{ + ID: d.nextBpID, + Filename: canonical, + Line: line, + Column: column, + } + for _, opt := range opts { + opt(bp) + } + + d.breakpoints[bp.ID] = bp + + lineMap, ok := d.bpIndex[canonical] + if !ok { + lineMap = make(map[int][]*Breakpoint) + d.bpIndex[canonical] = lineMap + } + lineMap[line] = append(lineMap[line], bp) + + // Register basename → canonical mapping for cross-resolution. + // This allows breakpoints set with full paths (e.g. from VS Code) to match + // goja sources registered with short names (e.g. "fibonacci.ts"), and vice versa. + base := filepath.Base(canonical) + if _, exists := d.bpByBase[base]; !exists { + d.bpByBase[base] = canonical + } + + atomic.AddInt32(&d.bpCount, 1) + return bp +} + +// RemoveBreakpoint removes the breakpoint with the given ID. +// Safe to call from any goroutine. +func (d *Debugger) RemoveBreakpoint(id int) bool { + d.bpMu.Lock() + defer d.bpMu.Unlock() + + bp, ok := d.breakpoints[id] + if !ok { + return false + } + delete(d.breakpoints, id) + + if lineMap, ok := d.bpIndex[bp.Filename]; ok { + bps := lineMap[bp.Line] + for i, b := range bps { + if b.ID == id { + bps[i] = bps[len(bps)-1] + bps[len(bps)-1] = nil + lineMap[bp.Line] = bps[:len(bps)-1] + break + } + } + if len(lineMap[bp.Line]) == 0 { + delete(lineMap, bp.Line) + } + if len(lineMap) == 0 { + delete(d.bpIndex, bp.Filename) + } + } + + atomic.AddInt32(&d.bpCount, -1) + return true +} + +// ClearBreakpoints removes all breakpoints for the given filename. +// Safe to call from any goroutine. +func (d *Debugger) ClearBreakpoints(filename string) { + canonical := canonicalizePath(filename) + + d.bpMu.Lock() + defer d.bpMu.Unlock() + + lineMap, ok := d.bpIndex[canonical] + if !ok { + return + } + + var count int32 + for _, bps := range lineMap { + for _, bp := range bps { + delete(d.breakpoints, bp.ID) + count++ + } + } + delete(d.bpIndex, canonical) + + atomic.AddInt32(&d.bpCount, -count) +} + +// GetBreakpoints returns a copy of all current breakpoints. +// Safe to call from any goroutine. +func (d *Debugger) GetBreakpoints() []*Breakpoint { + d.bpMu.RLock() + defer d.bpMu.RUnlock() + + result := make([]*Breakpoint, 0, len(d.breakpoints)) + for _, bp := range d.breakpoints { + bpCopy := *bp + result = append(result, &bpCopy) + } + return result +} + +// shouldPause checks whether the VM should pause at the current position. +// Called on the VM goroutine at statement boundaries. +// Returns the event type, the matching breakpoint (if any), and whether to pause. +// For log points, returns the breakpoint but shouldPause=false. +func (d *Debugger) shouldPause(vm *vm) (DebugEvent, *Breakpoint, bool) { + // Check breakpoints (fast path: skip if no breakpoints set) + if atomic.LoadInt32(&d.bpCount) > 0 && vm.prg != nil && vm.prg.src != nil { + pos := vm.prg.src.Position(vm.prg.sourceOffset(vm.pc)) + canonical := canonicalizePath(pos.Filename) + + d.bpMu.RLock() + lineMap, ok := d.bpIndex[canonical] + if !ok { + // Fallback: try matching by basename (handles VS Code full paths + // matching goja sources registered with short names, and vice versa). + base := filepath.Base(canonical) + if altPath, found := d.bpByBase[base]; found && altPath != canonical { + lineMap, ok = d.bpIndex[altPath] + } + } + if ok { + if bps, ok := lineMap[pos.Line]; ok { + for _, bp := range bps { + if bp.Column == 0 || bp.Column == pos.Column { + d.bpMu.RUnlock() + + // Increment hit count + bp.hitCount++ + + // Check hit condition + if bp.HitCondition != "" && !evalHitCondition(bp.HitCondition, bp.hitCount) { + return 0, nil, false + } + + // Check condition expression + if bp.Condition != "" { + val, err := vm.debugEval(0, bp.Condition) + if err != nil || !val.ToBoolean() { + return 0, nil, false + } + } + + // Log point: don't pause, return the bp for logging + if bp.LogMessage != "" { + return DebugEventBreakpoint, bp, false + } + + return DebugEventBreakpoint, bp, true + } + } + } + } + d.bpMu.RUnlock() + } + + // Check stepping + depth := len(vm.callStack) + switch d.stepAction { + case DebugStepOver: + if depth <= d.stepDepth { + return DebugEventStep, nil, true + } + case DebugStepIn: + return DebugEventStep, nil, true + case DebugStepOut: + if depth < d.stepDepth { + return DebugEventStep, nil, true + } + } + + return 0, nil, false +} + +// evalLogMessage evaluates a log point message, replacing {expr} with eval results. +var logInterpolationRegex = regexp.MustCompile(`\{([^}]+)\}`) + +func (d *Debugger) evalLogMessage(vm *vm, msg string) string { + return logInterpolationRegex.ReplaceAllStringFunc(msg, func(match string) string { + expr := match[1 : len(match)-1] // strip { and } + val, err := vm.debugEval(0, expr) + if err != nil { + return "{" + expr + "}" + } + return val.String() + }) +} + +// evalHitCondition evaluates a hit condition string against a hit count. +// Supported formats: "N" (every Nth hit), ">N", ">=N", "=", "<=", "!=", "==", ">", "<"} { + if strings.HasPrefix(expr, prefix) { + numStr := strings.TrimSpace(expr[len(prefix):]) + n, err := strconv.Atoi(numStr) + if err != nil { + return true // invalid → treat as always true + } + switch prefix { + case ">": + return hitCount > n + case ">=": + return hitCount >= n + case "<": + return hitCount < n + case "<=": + return hitCount <= n + case "==": + return hitCount == n + case "!=": + return hitCount != n + } + } + } + + // Plain number: every Nth hit (modulo) + n, err := strconv.Atoi(expr) + if err != nil || n <= 0 { + return true // invalid → treat as always true + } + return hitCount%n == 0 +} + +// invokeHook calls the debug hook and processes the returned action. +func (d *Debugger) invokeHook(vm *vm, event DebugEvent) { + pos := vm.currentPosition() + ctx := &DebugContext{vm: vm} + action := d.hook(ctx, event, pos) + d.stepAction = action + if action != DebugContinue { + d.stepDepth = len(vm.callStack) + } +} + +// dbgScopeRange returns the [start, end) range of dbgScopes entries that +// belong to the given stack frame. This replaces direct reads from context.dbgScopeLen +// with reads from the debugger's parallel frame stack. +func (vm *vm) dbgScopeRange(frameIndex int) (start, end int) { + if vm.dbg == nil || len(vm.dbgScopes) == 0 { + return 0, 0 + } + frames := vm.dbg.frames + // frameIndex 0 = current frame, which is above the top of frames/callStack + fEndIdx := len(frames) - frameIndex + if fEndIdx >= len(frames) { + end = len(vm.dbgScopes) + } else if fEndIdx >= 0 { + end = frames[fEndIdx].scopeLen + } + fStartIdx := fEndIdx - 1 + if fStartIdx >= 0 && fStartIdx < len(frames) { + start = frames[fStartIdx].scopeLen + } + return +} + +// debugScopes returns variable scopes for the given stack frame index. +func (vm *vm) debugScopes(frameIndex int) []DebugScope { + var scopes []DebugScope + + // Enumerate stack-register variables from dbgScopes for this frame. + if start, end := vm.dbgScopeRange(frameIndex); end > start { + // Iterate in reverse (innermost scope first) + for i := end - 1; i >= start; i-- { + ds := vm.dbgScopes[i] + scope := DebugScope{ + Type: "block", + Name: "Block", + } + for name, stackIdx := range ds.vars { + var val Value + if stackIdx >= 0 && stackIdx < len(vm.stack) { + val = nilSafe(vm.stack[stackIdx]) + } + if val == nil { + val = _undefined + } + scope.Variables = append(scope.Variables, DebugVariable{ + Name: name.String(), + Value: val, + }) + } + scopes = append(scopes, scope) + } + } + + // Determine which stash to start from based on frame index + var s *stash + if frameIndex == 0 { + s = vm.stash + } else { + idx := len(vm.callStack) - frameIndex + if idx >= 0 && idx < len(vm.callStack) { + s = vm.callStack[idx].stash + } + } + + isLocal := true + for s != nil { + scope := DebugScope{} + isGlobal := s == &vm.r.global.stash + + if isGlobal { + scope.Type = "global" + scope.Name = "Global" + } else if s.obj != nil { + scope.Type = "with" + scope.Name = "With Block" + } else if isLocal { + scope.Type = "local" + scope.Name = "Local" + isLocal = false + } else { + scope.Type = "closure" + scope.Name = "Closure" + } + + // Enumerate named stash bindings (closures, let/const in global, etc.) + if s.names != nil { + for name, idx := range s.names { + realIdx := idx &^ maskTyp + var val Value + if int(realIdx) < len(s.values) { + val = s.values[realIdx] + } + if val == nil { + val = _undefined + } + scope.Variables = append(scope.Variables, DebugVariable{ + Name: name.String(), + Value: val, + }) + } + } + + // For with-statement scopes, enumerate the object properties + if s.obj != nil { + for _, key := range s.obj.Keys() { + scope.Variables = append(scope.Variables, DebugVariable{ + Name: key, + Value: s.obj.Get(key), + }) + } + } + + // For global scope, also enumerate global object properties + // (global var/function declarations are properties of the global object) + if isGlobal { + globalObj := vm.r.globalObject + if globalObj != nil { + for _, key := range globalObj.Keys() { + scope.Variables = append(scope.Variables, DebugVariable{ + Name: key, + Value: globalObj.Get(key), + }) + } + } + } + + scopes = append(scopes, scope) + s = s.outer + } + return scopes +} + +// debugEval evaluates an expression in the context of the given stack frame. +// Debug hooks are disabled during evaluation to prevent re-entry. +func (vm *vm) debugEval(frameIndex int, expr string) (retVal Value, err error) { + // Save and restore debug state via defer to guarantee consistency + savedDbg := vm.dbg + savedStash := vm.stash + savedPrg := vm.prg + savedPc := vm.pc + savedSp := vm.sp + savedSb := vm.sb + savedArgs := vm.args + savedResult := vm.result + + defer func() { + if r := recover(); r != nil { + if ex, ok := r.(*InterruptedError); ok { + err = ex + } else if ex, ok := r.(*Exception); ok { + err = ex + } else { + panic(r) // re-panic for unexpected errors + } + } + vm.dbg = savedDbg + vm.stash = savedStash + vm.prg = savedPrg + vm.pc = savedPc + vm.sp = savedSp + vm.sb = savedSb + vm.args = savedArgs + vm.result = savedResult + }() + + // Compute scope range BEFORE disabling debug hooks (dbgScopeRange reads vm.dbg) + scopeStart, scopeEnd := vm.dbgScopeRange(frameIndex) + + // Disable debug hooks during eval + vm.dbg = nil + + // Switch to target frame's scope if needed + if frameIndex > 0 { + idx := len(vm.callStack) - frameIndex + if idx >= 0 && idx < len(vm.callStack) { + vm.stash = vm.callStack[idx].stash + } + } + + // Inject stack-register variables into a temporary stash so that + // loadDynamic (used by eval-compiled code) can find let/const variables + // that are optimized to stack registers. + if scopeEnd > scopeStart { + tmpStash := &stash{ + names: make(map[unistring.String]uint32), + outer: vm.stash, + } + start, end := scopeStart, scopeEnd + + // Add variables from innermost to outermost; inner wins on name collision. + seen := make(map[unistring.String]bool) + for i := end - 1; i >= start; i-- { + ds := vm.dbgScopes[i] + for name, stackIdx := range ds.vars { + if seen[name] { + continue + } + seen[name] = true + var val Value + if stackIdx >= 0 && stackIdx < len(vm.stack) { + val = nilSafe(vm.stack[stackIdx]) + } + if val == nil { + val = _undefined + } + si := uint32(len(tmpStash.values)) + tmpStash.names[name] = si | maskVar + tmpStash.values = append(tmpStash.values, val) + } + } + if len(tmpStash.names) > 0 { + vm.stash = tmpStash + } + } + + p, compileErr := vm.r.compile("", expr, false, false, vm) + if compileErr != nil { + return nil, compileErr + } + + vm.pushCtx() + vm.prg = p + vm.pc = 0 + vm.args = 0 + vm.result = _undefined + funcObj := Value(_undefined) + if sb := vm.sb; sb > 0 && sb <= len(vm.stack) { + funcObj = vm.stack[sb-1] + } + vm.push(funcObj) + vm.sb = vm.sp + vm.push(nil) // this + ex := vm.runTry() + retVal = vm.result + vm.popCtx() + if ex != nil { + return nil, ex + } + vm.sp -= 2 + return retVal, nil +} + +// debugSetVariable sets a variable in the specified scope and frame. +func (vm *vm) debugSetVariable(frameIndex, scopeIndex int, name string, value Value) error { + // Count how many dbgScope entries belong to this frame (they come first in scope list) + start, end := vm.dbgScopeRange(frameIndex) + dbgScopeCount := end - start + if dbgScopeCount > 0 { + + // If the target scope is a dbgScope (stack-register variable) + if scopeIndex < dbgScopeCount { + // dbgScopes are listed innermost first (reverse order) + dsIdx := end - 1 - scopeIndex + if dsIdx >= start && dsIdx < end { + ds := vm.dbgScopes[dsIdx] + uname := unistring.NewFromString(name) + if stackIdx, ok := ds.vars[uname]; ok { + if stackIdx >= 0 && stackIdx < len(vm.stack) { + vm.stack[stackIdx] = value + return nil + } + } + return fmt.Errorf("variable %q not found in block scope", name) + } + } + } + + // Walk the stash chain to find the target scope (adjusted for dbgScope count) + stashScopeIndex := scopeIndex - dbgScopeCount + var s *stash + if frameIndex == 0 { + s = vm.stash + } else { + idx := len(vm.callStack) - frameIndex + if idx >= 0 && idx < len(vm.callStack) { + s = vm.callStack[idx].stash + } + } + if s == nil { + return fmt.Errorf("invalid frame index: %d", frameIndex) + } + + // Skip to the requested scope index + for i := 0; i < stashScopeIndex && s != nil; i++ { + s = s.outer + } + if s == nil { + return fmt.Errorf("invalid scope index: %d", scopeIndex) + } + + isGlobal := s == &vm.r.global.stash + + // For with-statement scopes, set on the object + if s.obj != nil { + if s.obj.Get(name) != nil { + return s.obj.Set(name, value) + } + return fmt.Errorf("variable %q not found in with scope", name) + } + + // For named stash bindings + if s.names != nil { + if idx, ok := s.names[unistring.NewFromString(name)]; ok { + realIdx := idx &^ maskTyp + if int(realIdx) < len(s.values) { + s.values[realIdx] = value + return nil + } + } + } + + // For global scope, try the global object + if isGlobal { + globalObj := vm.r.globalObject + if globalObj != nil && globalObj.Get(name) != nil { + return globalObj.Set(name, value) + } + } + + return fmt.Errorf("variable %q not found", name) +} + +// canonicalizePath normalizes a filename for consistent breakpoint matching. +func canonicalizePath(filename string) string { + if filename == "" { + return filename + } + + // URLs: leave as-is + if strings.HasPrefix(filename, "http://") || strings.HasPrefix(filename, "https://") || strings.HasPrefix(filename, "data:") { + return filename + } + + // Virtual/module paths (e.g., , ): leave as-is + if strings.HasPrefix(filename, "<") { + return filename + } + + // Filesystem paths + cleaned := filepath.Clean(filename) + if !filepath.IsAbs(cleaned) { + if abs, err := filepath.Abs(cleaned); err == nil { + cleaned = abs + } + } + + // Windows case-insensitive normalization + if runtime.GOOS == "windows" { + cleaned = strings.ToLower(cleaned) + } + + return cleaned +} diff --git a/debugger/README.md b/debugger/README.md new file mode 100644 index 000000000..8d60f74be --- /dev/null +++ b/debugger/README.md @@ -0,0 +1,404 @@ +# goja/debugger - DAP Debug Adapter for Goja + +A [Debug Adapter Protocol](https://microsoft.github.io/debug-adapter-protocol/) (DAP) server that enables debugging JavaScript and TypeScript code running in the [goja](https://github.com/dop251/goja) runtime from VS Code and other DAP-compatible editors. + +## Features + +- **Breakpoints** — line breakpoints, conditional breakpoints, hit count breakpoints, log points +- **Exception breakpoints** — break on all exceptions or uncaught only +- **Stepping** — step over, step into, step out +- **Call stack** — full call stack with source-mapped positions +- **Variable inspection** — local, closure, and global scopes with object expansion +- **Set variable** — modify variable values while paused +- **Expression evaluation** — evaluate expressions in any stack frame context +- **Pause** — pause a running program at any time +- **`debugger` statement** — JavaScript `debugger` statements pause execution +- **Stop on entry** — optionally pause at the first statement +- **Source maps** — TypeScript debugging via `Program.SetSourceMap()` +- **Zero overhead** — no performance cost when debugger is not attached + +## Quick Start + +### 1. Install the VS Code extension + +The extension is at `debugger/vscode-goja-debugger/`. Install it in VS Code: + +```bash +cd debugger/vscode-goja-debugger +# Option A: symlink into VS Code extensions directory +ln -s "$(pwd)" ~/.vscode-server/extensions/vscode-goja-debugger + +# Option B: package and install as VSIX +npx @vscode/vsce package +code --install-extension vscode-goja-debugger-*.vsix +``` + +After installing, reload VS Code. + +### 2. Add debugging to your Go application + +Add a `--debug-port` flag to your application and start a DAP server when debugging is needed: + +```go +package main + +import ( + "flag" + "fmt" + "log" + + "github.com/dop251/goja" + "github.com/dop251/goja/debugger" +) + +func main() { + debugPort := flag.Int("debug-port", 0, "DAP debug port (0 = no debugging)") + flag.Parse() + + r := goja.New() + + // Your application's script + script := ` + function fibonacci(n) { + if (n <= 1) return n; + return fibonacci(n - 1) + fibonacci(n - 2); + } + fibonacci(10); + ` + + if *debugPort > 0 { + // Compile with debug info so all variables are visible + p, err := goja.CompileForDebug("script.js", script, false) + if err != nil { + log.Fatal(err) + } + + addr := fmt.Sprintf("127.0.0.1:%d", *debugPort) + session, err := debugger.ListenTCP(r, addr, func() error { + _, err := r.RunProgram(p) + return err + }) + if err != nil { + log.Fatal(err) + } + fmt.Fprintf(os.Stderr, "Debugger listening on %s\n", session.Addr) + if err := session.Wait(); err != nil { + log.Fatal(err) + } + } else { + // Normal execution — no debug overhead + _, err := r.RunString(script) + if err != nil { + log.Fatal(err) + } + } +} +``` + +### 3. Configure VS Code + +Create `.vscode/launch.json` in your project: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug JS in my app", + "type": "goja", + "request": "launch", + "program": "${workspaceFolder}", + "args": ["--other-flags"], + "port": 4711 + } + ] +} +``` + +The `launch` request tells the extension to: + +1. Run `go run . --port 4711 [args...]` in the `program` directory +2. Wait for the DAP server to start +3. Connect VS Code's debugger + +Set breakpoints in your `.js` or `.ts` files and press **F5**. + +## Compilation Modes + +Goja supports two compilation modes. The debugger needs debug metadata (`dbgNames` maps) to display `let`/`const` variables and function parameters. Without it, only `var` declarations and closure variables are visible. + +| Function | Debug info | Use case | +| -------- | ---------- | -------- | +| `goja.Compile()` | No | Production — zero overhead | +| `goja.CompileForDebug()` | Yes | Pre-compiled programs for debugging | +| `r.Compile()` | Auto | Detects debugger at compile time | +| `r.RunString()` / `r.RunScript()` | Auto | Auto-detects if debugger is attached | + +**Recommendation:** Use `r.Compile()` or `r.RunString()` — they automatically enable debug info when a debugger is attached, and skip it when not. + +```go +// Auto-detect: debug info is generated only when r.SetDebugger() has been called +p, err := r.Compile("script.js", src, false) +``` + +## VS Code Launch Configurations + +### Launch mode — extension builds and runs your Go app + +```json +{ + "name": "Debug JS in goja", + "type": "goja", + "request": "launch", + "program": "${workspaceFolder}/cmd/myapp", + "args": ["--config", "dev.yaml"], + "port": 4711, + "buildArgs": ["-tags", "debug"], + "cwd": "${workspaceFolder}", + "env": { "DEBUG": "1" } +} +``` + +| Field | Description | +| ----- | ----------- | +| `program` | Path to the Go package directory (must contain `main.go`) | +| `args` | Arguments passed to the Go program after `--port` | +| `port` | TCP port for the DAP server (default: `4711`) | +| `buildArgs` | Extra flags for `go run` (e.g., `-race`, `-tags`) | +| `cwd` | Working directory (defaults to `program` directory) | +| `env` | Extra environment variables | + +The extension runs: `go run [buildArgs...] . --port PORT [args...]` + +### Attach mode — connect to an already-running DAP server + +```json +{ + "name": "Attach to goja", + "type": "goja", + "request": "attach", + "port": 4711, + "host": "127.0.0.1" +} +``` + +Start your Go application with debugging enabled first, then use this to connect. + +### Compound mode — debug Go and JavaScript simultaneously + +You can set breakpoints in both Go source files and JavaScript/TypeScript files at the same time using a compound launch configuration: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Go: my app", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "${workspaceFolder}/cmd/myapp", + "args": ["--debug-port", "4711", "--script", "app.ts"] + }, + { + "name": "Attach to goja DAP", + "type": "goja", + "request": "attach", + "port": 4711 + } + ], + "compounds": [ + { + "name": "Debug Go + JavaScript", + "configurations": ["Go: my app", "Attach to goja DAP"], + "stopAll": true + } + ] +} +``` + +Select **"Debug Go + JavaScript"** from the debug dropdown and press F5. This starts two debug sessions: + +1. **Go debugger (delve)** — set breakpoints in `.go` files, step through Go code +2. **Goja debugger (DAP)** — set breakpoints in `.js`/`.ts` files, inspect JS variables + +The goja attach session automatically retries until the DAP server is ready, so it handles the startup timing gracefully. + +## TypeScript / Source Map Support + +If your code is transpiled (e.g., TypeScript via esbuild), attach a source map so breakpoints and stack traces reference the original source: + +```go +import "github.com/go-sourcemap/sourcemap" + +// Compile the transpiled JavaScript +p, _ := goja.CompileForDebug("app.ts", transpiledJS, false) + +// Parse and attach the source map +sm, _ := sourcemap.Parse("", sourceMapJSON) +p.SetSourceMap(sm) + +_, err := r.RunProgram(p) +``` + +Once attached, breakpoints set on `.ts` files resolve correctly through the source map. + +## Go API Reference + +### Debugger setup (in `goja` package) + +```go +// Attach a debugger to a runtime. +r.SetDebugger(dbg) + +// Request the VM to pause at the next statement (safe from any goroutine). +r.RequestPause() + +// Compile with debug info for full variable visibility. +p, err := goja.CompileForDebug(name, src, strict) + +// Compile with automatic debug detection (uses debug mode if debugger is attached). +p, err := r.Compile(name, src, strict) +``` + +### DAP server (in `goja/debugger` package) + +```go +// Create a DAP server over any io.Reader/io.Writer (stdio, TCP, etc.) +srv := debugger.NewServer(runtime, reader, writer, runFunc) +err := srv.Run() // blocks until disconnect + +// Listen on TCP, accept one client, run debug session. +session, err := debugger.ListenTCP(runtime, "127.0.0.1:4711", runFunc) +fmt.Println("Listening on", session.Addr) +err = session.Wait() // blocks until session ends +``` + +### Low-level debugger API (in `goja` package) + +For custom integrations that don't use DAP: + +```go +// Create a debugger with a custom hook +dbg := goja.NewDebugger(func(ctx *goja.DebugContext, event goja.DebugEvent, pos goja.DebugPosition) goja.DebugAction { + fmt.Printf("Paused at %s:%d\n", pos.Filename, pos.Line) + + // Inspect the call stack + for _, frame := range ctx.CallStack() { + fmt.Printf(" %s at %s:%d\n", frame.FuncName(), frame.Position().Filename, frame.Position().Line) + } + + // Inspect variables in the current frame + for _, scope := range ctx.Scopes(0) { + for _, v := range scope.Variables { + fmt.Printf(" %s = %v\n", v.Name, v.Value) + } + } + + // Evaluate an expression in the current frame + val, err := ctx.Eval(0, "myVar + 1") + + return goja.DebugActionContinue +}) + +// Set breakpoints +dbg.SetBreakpoint("script.js", 10, 0) +dbg.SetBreakpoint("script.js", 20, 0, goja.WithCondition("x > 5")) +dbg.SetBreakpoint("script.js", 30, 0, goja.WithHitCondition("3")) +dbg.SetBreakpoint("script.js", 40, 0, goja.WithLogMessage("x = {x}")) + +// Exception breakpoints +dbg.SetExceptionBreakpoints([]string{"all"}) // break on all exceptions +dbg.SetExceptionBreakpoints([]string{"uncaught"}) // break on uncaught only + +// Attach and run +r.SetDebugger(dbg) +r.RunString(script) +r.SetDebugger(nil) // detach when done +``` + +### Debug actions + +The hook function returns a `DebugAction` that tells the VM what to do next: + +| Action | Behavior | +| ------ | -------- | +| `DebugActionContinue` | Resume execution until next breakpoint | +| `DebugActionStepOver` | Execute current line, stop at next line | +| `DebugActionStepInto` | Step into function calls | +| `DebugActionStepOut` | Run until the current function returns | + +### Debug events + +The hook receives a `DebugEvent` indicating why execution paused: + +| Event | Trigger | +| ----- | ------- | +| `DebugEventBreakpoint` | Hit a breakpoint | +| `DebugEventStep` | Step operation completed | +| `DebugEventDebuggerStatement` | `debugger` statement in JS | +| `DebugEventPause` | `RequestPause()` was called | +| `DebugEventException` | Exception thrown (when exception breakpoints are active) | + +## Architecture + +The DAP server uses a two-goroutine model: + +```text +VS Code (client) DAP Server goroutine VM goroutine + | | | + |-- SetBreakpoints ------->| | + |<-- Response -------------| | + | | | + |-- ConfigurationDone ---->|--- starts VM goroutine -->| + | | | + | |<-- stopped (breakpoint) --| (hook blocks) + |<-- StoppedEvent ---------| | + | | | + |-- StackTrace ----------->|--- inspect request ------>| + | |<-- inspect response ------| + |<-- Response -------------| | + | | | + |-- Continue ------------->|--- resume action -------->| (hook returns) + | | |-- continues +``` + +When the VM pauses, the debug hook blocks the VM goroutine. The server goroutine proxies inspection requests (StackTrace, Scopes, Variables, Evaluate, SetVariable) to the VM goroutine via channels, ensuring all VM state access is single-threaded. + +## Supported DAP Requests + +| Request | Status | +| ------- | ------ | +| initialize | Supported | +| launch | Supported | +| attach | Supported | +| setBreakpoints | Supported (line, conditional, hit count, log point) | +| setExceptionBreakpoints | Supported (`all`, `uncaught` filters) | +| configurationDone | Supported | +| threads | Supported (single thread) | +| stackTrace | Supported | +| scopes | Supported | +| variables | Supported (with object expansion) | +| setVariable | Supported | +| evaluate | Supported (in any frame context) | +| continue | Supported | +| next (step over) | Supported | +| stepIn | Supported | +| stepOut | Supported | +| pause | Supported | +| terminate | Supported | +| disconnect | Supported | + +## Thread Safety + +- The `Server` manages all concurrency internally +- The `runFunc` is called on a new goroutine; all VM access should happen there +- Breakpoints can be set/cleared from any goroutine (`Debugger` uses `sync.RWMutex`) +- `RequestPause()` is safe to call from any goroutine +- After `Run()` returns, the debugger is detached and the runtime can be reused + +## Limitations + +- **Single thread** — goja is single-threaded; the server reports one thread +- **Eval side effects** — expression evaluation can modify program state (same as JavaScript `eval()`) +- **One debug session** — `ListenTCP` accepts one client connection at a time diff --git a/debugger/go.mod b/debugger/go.mod new file mode 100644 index 000000000..b0c037e51 --- /dev/null +++ b/debugger/go.mod @@ -0,0 +1,17 @@ +module github.com/dop251/goja/debugger + +go 1.20 + +require ( + github.com/dop251/goja v0.0.0 + github.com/google/go-dap v0.12.0 +) + +require ( + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + golang.org/x/text v0.3.8 // indirect +) + +replace github.com/dop251/goja => ../ diff --git a/debugger/go.sum b/debugger/go.sum new file mode 100644 index 000000000..aec5c15e9 --- /dev/null +++ b/debugger/go.sum @@ -0,0 +1,12 @@ +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/google/go-dap v0.12.0 h1:rVcjv3SyMIrpaOoTAdFDyHs99CwVOItIJGKLQFQhNeM= +github.com/google/go-dap v0.12.0/go.mod h1:tNjCASCm5cqePi/RVXXWEVqtnNLV1KTWtYOqu6rZNzc= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/debugger/handlers.go b/debugger/handlers.go new file mode 100644 index 000000000..055331d90 --- /dev/null +++ b/debugger/handlers.go @@ -0,0 +1,512 @@ +package debugger + +import ( + "encoding/json" + "fmt" + "net/url" + "path/filepath" + "reflect" + "strings" + + "github.com/dop251/goja" + dap "github.com/google/go-dap" +) + +// normalizeSourcePath extracts the local filesystem path from a +// vscode-remote URI. In WSL/SSH environments VS Code sends source paths +// like "vscode-remote://wsl+distro/home/user/file.ts"; the DAP server +// must store and return plain filesystem paths so VS Code doesn't +// double-wrap them. +func normalizeSourcePath(p string) string { + // Handle URI scheme (vscode-remote://wsl+distro/path) + if strings.Contains(p, "://") { + if u, err := url.Parse(p); err == nil && u.Path != "" { + return u.Path + } + } + return p +} + +// handleMessage dispatches a DAP message to the appropriate handler. +// Returns true if the server should exit the Run() loop. +func (s *Server) handleMessage(msg dap.Message) bool { + // After termination, only accept Disconnect + if s.terminated { + if req, ok := msg.(*dap.DisconnectRequest); ok { + return s.onDisconnect(req) + } + if req, ok := msg.(dap.RequestMessage); ok { + r := req.GetRequest() + s.sendError(r.Seq, r.Command, "Program has terminated") + } + return false + } + + switch req := msg.(type) { + case *dap.InitializeRequest: + s.onInitialize(req) + case *dap.LaunchRequest: + s.onLaunch(req) + case *dap.AttachRequest: + s.onAttach(req) + case *dap.SetBreakpointsRequest: + s.onSetBreakpoints(req) + case *dap.SetExceptionBreakpointsRequest: + s.onSetExceptionBreakpoints(req) + case *dap.ConfigurationDoneRequest: + s.onConfigurationDone(req) + case *dap.ThreadsRequest: + s.onThreads(req) + case *dap.StackTraceRequest: + s.onStackTrace(req) + case *dap.ScopesRequest: + s.onScopes(req) + case *dap.VariablesRequest: + s.onVariables(req) + case *dap.EvaluateRequest: + s.onEvaluate(req) + case *dap.SetVariableRequest: + s.onSetVariable(req) + case *dap.ContinueRequest: + s.onContinue(req) + case *dap.NextRequest: + s.onNext(req) + case *dap.StepInRequest: + s.onStepIn(req) + case *dap.StepOutRequest: + s.onStepOut(req) + case *dap.PauseRequest: + s.onPause(req) + case *dap.DisconnectRequest: + return s.onDisconnect(req) + case *dap.TerminateRequest: + s.onTerminate(req) + default: + if req, ok := msg.(dap.RequestMessage); ok { + r := req.GetRequest() + s.sendError(r.Seq, r.Command, fmt.Sprintf("Unsupported request: %s", r.Command)) + } + } + return false +} + +func (s *Server) onInitialize(req *dap.InitializeRequest) { + s.send(&dap.InitializeResponse{ + Response: s.newResponse(req.Seq, req.Command), + Body: dap.Capabilities{ + SupportsConfigurationDoneRequest: true, + SupportsEvaluateForHovers: true, + SupportsTerminateRequest: true, + SupportsConditionalBreakpoints: true, + SupportsHitConditionalBreakpoints: true, + SupportsLogPoints: true, + SupportsSetVariable: true, + ExceptionBreakpointFilters: []dap.ExceptionBreakpointsFilter{ + {Filter: "all", Label: "All Exceptions", Description: "Break on all exceptions"}, + {Filter: "uncaught", Label: "Uncaught Exceptions", Description: "Break on uncaught exceptions", Default: true}, + }, + }, + }) + s.send(&dap.InitializedEvent{Event: s.newEvent("initialized")}) +} + +func (s *Server) onLaunch(req *dap.LaunchRequest) { + var args map[string]interface{} + if err := json.Unmarshal(req.Arguments, &args); err == nil { + s.stopOnEntry, _ = args["stopOnEntry"].(bool) + } + s.launched = true + s.send(&dap.LaunchResponse{Response: s.newResponse(req.Seq, req.Command)}) +} + +func (s *Server) onAttach(req *dap.AttachRequest) { + s.launched = true + s.send(&dap.AttachResponse{Response: s.newResponse(req.Seq, req.Command)}) +} + +func (s *Server) onSetBreakpoints(req *dap.SetBreakpointsRequest) { + source := req.Arguments.Source + path := normalizeSourcePath(source.Path) + if path == "" { + path = source.Name + } + + // Remember the local filesystem path for this source basename so we + // can map goja's short source names back to paths the IDE can open. + if filepath.IsAbs(path) { + s.sourcePathMap[filepath.Base(path)] = path + } + + s.debugger.ClearBreakpoints(path) + + bps := make([]dap.Breakpoint, len(req.Arguments.Breakpoints)) + for i, sbp := range req.Arguments.Breakpoints { + var opts []goja.BreakpointOption + if sbp.Condition != "" { + opts = append(opts, goja.WithCondition(sbp.Condition)) + } + if sbp.HitCondition != "" { + opts = append(opts, goja.WithHitCondition(sbp.HitCondition)) + } + if sbp.LogMessage != "" { + opts = append(opts, goja.WithLogMessage(sbp.LogMessage)) + } + bp := s.debugger.SetBreakpoint(path, sbp.Line, 0, opts...) + bps[i] = dap.Breakpoint{ + Id: bp.ID, + Verified: true, + Source: &dap.Source{Name: filepath.Base(path), Path: path}, + Line: sbp.Line, + } + } + s.send(&dap.SetBreakpointsResponse{ + Response: s.newResponse(req.Seq, req.Command), + Body: dap.SetBreakpointsResponseBody{Breakpoints: bps}, + }) +} + +func (s *Server) onSetExceptionBreakpoints(req *dap.SetExceptionBreakpointsRequest) { + s.debugger.SetExceptionBreakpoints(req.Arguments.Filters) + s.send(&dap.SetExceptionBreakpointsResponse{ + Response: s.newResponse(req.Seq, req.Command), + }) +} + +func (s *Server) onConfigurationDone(req *dap.ConfigurationDoneRequest) { + s.configured = true + s.send(&dap.ConfigurationDoneResponse{Response: s.newResponse(req.Seq, req.Command)}) + + if s.stopOnEntry { + s.runtime.RequestPause() + } + + // Start VM on separate goroutine + go func() { + err := s.runFunc() + s.doneCh <- err + }() +} + +func (s *Server) onThreads(req *dap.ThreadsRequest) { + s.send(&dap.ThreadsResponse{ + Response: s.newResponse(req.Seq, req.Command), + Body: dap.ThreadsResponseBody{ + Threads: []dap.Thread{{Id: 1, Name: "main"}}, + }, + }) +} + +func (s *Server) onStackTrace(req *dap.StackTraceRequest) { + result, ok := s.inspect(func(ctx *goja.DebugContext) interface{} { + return ctx.CallStack() + }) + if !ok { + s.sendError(req.Seq, req.Command, "VM is not paused") + return + } + gojaFrames := result.([]goja.StackFrame) + + frames := make([]dap.StackFrame, len(gojaFrames)) + for i, f := range gojaFrames { + pos := f.Position() + name := f.FuncName() + if name == "" { + name = "" + } + // Resolve the source path: if goja has a short name (e.g. "fibonacci.ts"), + // map it to the full path the IDE knows about. + sourcePath := pos.Filename + if !filepath.IsAbs(sourcePath) { + if fullPath, ok := s.sourcePathMap[filepath.Base(sourcePath)]; ok { + sourcePath = fullPath + } + } + frames[i] = dap.StackFrame{ + Id: i + 1, // 1-based frame IDs + Name: name, + Source: &dap.Source{ + Name: filepath.Base(sourcePath), + Path: sourcePath, + }, + Line: pos.Line, + Column: pos.Column, + } + } + s.send(&dap.StackTraceResponse{ + Response: s.newResponse(req.Seq, req.Command), + Body: dap.StackTraceResponseBody{ + StackFrames: frames, + TotalFrames: len(frames), + }, + }) +} + +func (s *Server) onScopes(req *dap.ScopesRequest) { + frameIndex := req.Arguments.FrameId - 1 // Convert 1-based to 0-based + result, ok := s.inspect(func(ctx *goja.DebugContext) interface{} { + return ctx.Scopes(frameIndex) + }) + if !ok { + s.sendError(req.Seq, req.Command, "VM is not paused") + return + } + gojaScopes := result.([]goja.DebugScope) + + scopes := make([]dap.Scope, len(gojaScopes)) + for i, gs := range gojaScopes { + ref := s.refs.AddScope(frameIndex, i, gs) + scopes[i] = dap.Scope{ + Name: gs.Name, + VariablesReference: ref, + Expensive: gs.Type == "global", + } + } + s.send(&dap.ScopesResponse{ + Response: s.newResponse(req.Seq, req.Command), + Body: dap.ScopesResponseBody{Scopes: scopes}, + }) +} + +func (s *Server) onVariables(req *dap.VariablesRequest) { + ref := req.Arguments.VariablesReference + entry, ok := s.refs.Get(ref) + if !ok { + s.sendError(req.Seq, req.Command, "Invalid variable reference") + return + } + + var vars []dap.Variable + switch v := entry.(type) { + case scopeEntry: + for _, dv := range v.scope.Variables { + vars = append(vars, s.valueToVariable(dv.Name, dv.Value)) + } + case objectEntry: + result, inspectOk := s.inspect(func(ctx *goja.DebugContext) interface{} { + obj := v.object + var props []dap.Variable + for _, key := range obj.Keys() { + val := obj.Get(key) + props = append(props, s.valueToVariable(key, val)) + } + return props + }) + if !inspectOk { + s.sendError(req.Seq, req.Command, "VM is not paused") + return + } + vars = result.([]dap.Variable) + } + + if vars == nil { + vars = []dap.Variable{} + } + + s.send(&dap.VariablesResponse{ + Response: s.newResponse(req.Seq, req.Command), + Body: dap.VariablesResponseBody{Variables: vars}, + }) +} + +func (s *Server) onEvaluate(req *dap.EvaluateRequest) { + frameIndex := 0 + if req.Arguments.FrameId > 0 { + frameIndex = req.Arguments.FrameId - 1 + } + + result, ok := s.inspect(func(ctx *goja.DebugContext) interface{} { + val, err := ctx.Eval(frameIndex, req.Arguments.Expression) + return evalResult{val, err} + }) + if !ok { + s.sendError(req.Seq, req.Command, "VM is not paused") + return + } + er := result.(evalResult) + if er.err != nil { + s.sendError(req.Seq, req.Command, er.err.Error()) + return + } + + resp := &dap.EvaluateResponse{ + Response: s.newResponse(req.Seq, req.Command), + Body: dap.EvaluateResponseBody{ + Result: er.val.String(), + }, + } + if obj, ok := er.val.(*goja.Object); ok { + resp.Body.VariablesReference = s.refs.AddObject(obj) + } + s.send(resp) +} + +func (s *Server) onSetVariable(req *dap.SetVariableRequest) { + ref := req.Arguments.VariablesReference + entry, ok := s.refs.Get(ref) + if !ok { + s.sendError(req.Seq, req.Command, "Invalid variable reference") + return + } + + result, inspectOk := s.inspect(func(ctx *goja.DebugContext) interface{} { + // Evaluate the new value expression in the target frame's scope, + // not always frame 0, so identifiers resolve correctly. + frameIndex := 0 + if se, ok := entry.(scopeEntry); ok { + frameIndex = se.frameIndex + } + newVal, err := ctx.Eval(frameIndex, req.Arguments.Value) + if err != nil { + return evalResult{err: err} + } + + switch e := entry.(type) { + case scopeEntry: + err = ctx.SetVariable(e.frameIndex, e.scopeIndex, req.Arguments.Name, newVal) + case objectEntry: + err = e.object.Set(req.Arguments.Name, newVal) + } + if err != nil { + return evalResult{err: err} + } + return evalResult{val: newVal} + }) + if !inspectOk { + s.sendError(req.Seq, req.Command, "VM is not paused") + return + } + er := result.(evalResult) + if er.err != nil { + s.sendError(req.Seq, req.Command, er.err.Error()) + return + } + + resp := &dap.SetVariableResponse{ + Response: s.newResponse(req.Seq, req.Command), + Body: dap.SetVariableResponseBody{ + Value: er.val.String(), + }, + } + if obj, ok := er.val.(*goja.Object); ok { + resp.Body.VariablesReference = s.refs.AddObject(obj) + } + s.send(resp) +} + +func (s *Server) onContinue(req *dap.ContinueRequest) { + if !s.paused { + s.sendError(req.Seq, req.Command, "VM is not paused") + return + } + s.send(&dap.ContinueResponse{ + Response: s.newResponse(req.Seq, req.Command), + Body: dap.ContinueResponseBody{AllThreadsContinued: true}, + }) + s.paused = false + s.resumeCh <- goja.DebugContinue +} + +func (s *Server) onNext(req *dap.NextRequest) { + if !s.paused { + s.sendError(req.Seq, req.Command, "VM is not paused") + return + } + s.send(&dap.NextResponse{Response: s.newResponse(req.Seq, req.Command)}) + s.paused = false + s.resumeCh <- goja.DebugStepOver +} + +func (s *Server) onStepIn(req *dap.StepInRequest) { + if !s.paused { + s.sendError(req.Seq, req.Command, "VM is not paused") + return + } + s.send(&dap.StepInResponse{Response: s.newResponse(req.Seq, req.Command)}) + s.paused = false + s.resumeCh <- goja.DebugStepIn +} + +func (s *Server) onStepOut(req *dap.StepOutRequest) { + if !s.paused { + s.sendError(req.Seq, req.Command, "VM is not paused") + return + } + s.send(&dap.StepOutResponse{Response: s.newResponse(req.Seq, req.Command)}) + s.paused = false + s.resumeCh <- goja.DebugStepOut +} + +func (s *Server) onPause(req *dap.PauseRequest) { + s.runtime.RequestPause() + s.send(&dap.PauseResponse{Response: s.newResponse(req.Seq, req.Command)}) +} + +func (s *Server) onTerminate(req *dap.TerminateRequest) { + s.runtime.Interrupt("terminated by debugger") + s.send(&dap.TerminateResponse{Response: s.newResponse(req.Seq, req.Command)}) +} + +func (s *Server) onDisconnect(req *dap.DisconnectRequest) bool { + // Close disconnectCh to unblock debugHook regardless of whether + // the stopped event has been processed yet (avoids deadlock when + // disconnect races with a pending stop notification). + close(s.disconnectCh) + s.paused = false + s.runtime.Interrupt("debugger disconnected") + s.send(&dap.DisconnectResponse{Response: s.newResponse(req.Seq, req.Command)}) + return true +} + +// valueToVariable converts a goja Value to a DAP Variable. +func (s *Server) valueToVariable(name string, val goja.Value) dap.Variable { + if val == nil { + return dap.Variable{Name: name, Value: "undefined", Type: "undefined"} + } + + v := dap.Variable{ + Name: name, + Value: val.String(), + } + + exportType := val.ExportType() + if exportType != nil { + v.Type = exportType.Kind().String() + } + + switch { + case goja.IsNull(val): + v.Type = "null" + v.Value = "null" + case goja.IsUndefined(val): + v.Type = "undefined" + v.Value = "undefined" + case goja.IsNaN(val): + v.Type = "number" + v.Value = "NaN" + case goja.IsInfinity(val): + v.Type = "number" + v.Value = "Infinity" + default: + if obj, ok := val.(*goja.Object); ok { + v.VariablesReference = s.refs.AddObject(obj) + // Determine a more specific type + exported := obj.Export() + if exported != nil { + rt := reflect.TypeOf(exported) + switch rt.Kind() { + case reflect.Func: + v.Type = "function" + v.Value = "function" + case reflect.Slice, reflect.Array: + v.Type = "array" + default: + v.Type = "object" + } + } else { + v.Type = "object" + } + } + } + + return v +} diff --git a/debugger/refs.go b/debugger/refs.go new file mode 100644 index 000000000..27c485fe2 --- /dev/null +++ b/debugger/refs.go @@ -0,0 +1,58 @@ +package debugger + +import "github.com/dop251/goja" + +// RefManager maps DAP integer variable references to goja objects or scope data. +// It is only accessed from the server goroutine, so no mutex is needed. +type RefManager struct { + nextRef int + refs map[int]interface{} +} + +type scopeEntry struct { + frameIndex int + scopeIndex int + scope goja.DebugScope +} + +type objectEntry struct { + object *goja.Object +} + +// NewRefManager creates a new RefManager. +func NewRefManager() *RefManager { + return &RefManager{ + refs: make(map[int]interface{}), + } +} + +// AddScope stores a scope and returns a reference ID. +func (rm *RefManager) AddScope(frameIndex, scopeIndex int, scope goja.DebugScope) int { + rm.nextRef++ + rm.refs[rm.nextRef] = scopeEntry{ + frameIndex: frameIndex, + scopeIndex: scopeIndex, + scope: scope, + } + return rm.nextRef +} + +// AddObject stores a goja Object and returns a reference ID. +func (rm *RefManager) AddObject(obj *goja.Object) int { + rm.nextRef++ + rm.refs[rm.nextRef] = objectEntry{object: obj} + return rm.nextRef +} + +// Get retrieves the stored value for a reference ID. +func (rm *RefManager) Get(ref int) (interface{}, bool) { + v, ok := rm.refs[ref] + return v, ok +} + +// Clear drops all references. Called on each new stop to invalidate +// previous scope/variable references and allow GC of goja objects. +func (rm *RefManager) Clear() { + rm.refs = make(map[int]interface{}) + rm.nextRef = 0 +} diff --git a/debugger/server.go b/debugger/server.go new file mode 100644 index 000000000..c47c75db9 --- /dev/null +++ b/debugger/server.go @@ -0,0 +1,454 @@ +package debugger + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net" + "sync" + "time" + + "github.com/dop251/goja" + dap "github.com/google/go-dap" +) + +// Server is a DAP (Debug Adapter Protocol) server for a goja Runtime. +// It bridges DAP messages from an IDE (e.g., VS Code) to goja's debug API. +type Server struct { + runtime *goja.Runtime + debugger *goja.Debugger + reader *bufio.Reader + writer io.Writer + runFunc func() error + + // Outgoing message sequencing + sendMu sync.Mutex + seq int + + // Reference manager (server-goroutine only) + refs *RefManager + + // Hook bridge channels + stoppedCh chan stopInfo // VM → Server: VM paused + inspectCh chan inspectRequest // Server → VM: inspect while paused + resumeCh chan goja.DebugAction // Server → VM: resume + logCh chan logEntry // VM → Server: log point output + disconnectCh chan struct{} // closed on disconnect to unblock debugHook + + // Session state + configured bool + launched bool + stopOnEntry bool + paused bool + terminated bool + vmDone bool + + // Source path mapping: basename → full path from IDE. + // Populated when setBreakpoints sends an absolute path; + // used by onStackTrace to return paths the IDE can open. + sourcePathMap map[string]string + + // Termination + doneCh chan error // VM goroutine signals completion +} + +type stopInfo struct { + event goja.DebugEvent + pos goja.DebugPosition +} + +type inspectRequest struct { + fn func(ctx *goja.DebugContext) interface{} + resultCh chan<- interface{} +} + +type evalResult struct { + val goja.Value + err error +} + +type logEntry struct { + message string + pos goja.DebugPosition +} + +type dapMessage struct { + msg dap.Message + err error +} + +// NewServer creates a new DAP debug adapter server. +// reader/writer are the DAP transport (typically stdin/stdout or a TCP connection). +// runFunc is called to start JS execution after ConfigurationDone. +// It runs on a new goroutine and should execute JS code via the runtime. +func NewServer(r *goja.Runtime, reader io.Reader, writer io.Writer, runFunc func() error) *Server { + return &Server{ + runtime: r, + reader: bufio.NewReader(reader), + writer: writer, + runFunc: runFunc, + refs: NewRefManager(), + stoppedCh: make(chan stopInfo, 1), + inspectCh: make(chan inspectRequest), + resumeCh: make(chan goja.DebugAction), + logCh: make(chan logEntry, 16), + doneCh: make(chan error, 1), + disconnectCh: make(chan struct{}), + sourcePathMap: make(map[string]string), + } +} + +// Run starts the DAP message loop. It blocks until disconnect or error. +func (s *Server) Run() error { + s.debugger = goja.NewDebugger(s.debugHook) + s.debugger.SetLogHook(func(msg string, pos goja.DebugPosition) { + s.logCh <- logEntry{message: msg, pos: pos} + }) + s.runtime.SetDebugger(s.debugger) + + // Detach debugger only after the VM goroutine has finished, + // to avoid racing with VM goroutine reads of vm.dbg. + defer func() { + if s.configured && !s.vmDone { + <-s.doneCh + } + s.runtime.SetDebugger(nil) + }() + + msgCh := make(chan dapMessage, 1) + go func() { + for { + msg, err := dap.ReadProtocolMessage(s.reader) + msgCh <- dapMessage{msg, err} + if err != nil { + return + } + } + }() + + for { + select { + case dm := <-msgCh: + if dm.err != nil { + return dm.err + } + if done := s.handleMessage(dm.msg); done { + return nil + } + + case stop := <-s.stoppedCh: + s.refs.Clear() + reason := eventToReason(stop.event) + s.send(&dap.StoppedEvent{ + Event: s.newEvent("stopped"), + Body: dap.StoppedEventBody{ + Reason: reason, + ThreadId: 1, + AllThreadsStopped: true, + }, + }) + s.paused = true + + case entry := <-s.logCh: + s.send(&dap.OutputEvent{ + Event: s.newEvent("output"), + Body: dap.OutputEventBody{ + Category: "console", + Output: entry.message + "\n", + Source: &dap.Source{ + Path: entry.pos.Filename, + }, + Line: entry.pos.Line, + }, + }) + + case err := <-s.doneCh: + _ = err + s.send(&dap.TerminatedEvent{Event: s.newEvent("terminated")}) + s.paused = false + s.terminated = true + s.vmDone = true + } + } +} + +// debugHook is the debug hook called on the VM goroutine when the VM pauses. +// It acts as a bridge: notifies the server goroutine, then blocks processing +// inspection requests until a resume action is received. +func (s *Server) debugHook(ctx *goja.DebugContext, event goja.DebugEvent, pos goja.DebugPosition) goja.DebugAction { + // Notify server goroutine that VM is paused + select { + case s.stoppedCh <- stopInfo{event: event, pos: pos}: + case <-s.disconnectCh: + return goja.DebugContinue + } + + // Block in select loop: process inspect requests until resume + for { + select { + case req := <-s.inspectCh: + result := req.fn(ctx) // Execute on VM goroutine with valid DebugContext + req.resultCh <- result + case action := <-s.resumeCh: + return action + case <-s.disconnectCh: + return goja.DebugContinue + } + } +} + +// inspect executes a function on the VM goroutine while it is paused. +// Returns (result, true) on success, or (nil, false) if the VM is not paused. +func (s *Server) inspect(fn func(ctx *goja.DebugContext) interface{}) (interface{}, bool) { + if !s.paused { + return nil, false + } + resultCh := make(chan interface{}, 1) + s.inspectCh <- inspectRequest{fn: fn, resultCh: resultCh} + return <-resultCh, true +} + +// send writes a DAP message to the transport with proper sequencing. +func (s *Server) send(msg dap.Message) { + s.sendMu.Lock() + defer s.sendMu.Unlock() + s.seq++ + setSeq(msg, s.seq) + _ = dap.WriteProtocolMessage(s.writer, msg) +} + +// newResponse creates a success Response base for the given request. +func (s *Server) newResponse(requestSeq int, command string) dap.Response { + return dap.Response{ + ProtocolMessage: dap.ProtocolMessage{Type: "response"}, + RequestSeq: requestSeq, + Command: command, + Success: true, + } +} + +// newEvent creates an Event base with the given event name. +func (s *Server) newEvent(event string) dap.Event { + return dap.Event{ + ProtocolMessage: dap.ProtocolMessage{Type: "event"}, + Event: event, + } +} + +// sendError sends an error response for a request. +func (s *Server) sendError(requestSeq int, command string, message string) { + s.send(&dap.ErrorResponse{ + Response: dap.Response{ + ProtocolMessage: dap.ProtocolMessage{Type: "response"}, + RequestSeq: requestSeq, + Command: command, + Success: false, + Message: message, + }, + }) +} + +// eventToReason maps a goja DebugEvent to a DAP stopped reason string. +func eventToReason(event goja.DebugEvent) string { + switch event { + case goja.DebugEventBreakpoint: + return "breakpoint" + case goja.DebugEventStep: + return "step" + case goja.DebugEventPause: + return "pause" + case goja.DebugEventDebuggerStmt: + return "breakpoint" + case goja.DebugEventEntry: + return "entry" + case goja.DebugEventException: + return "exception" + default: + return "unknown" + } +} + +// setSeq sets the Seq field on any DAP message. +func setSeq(msg dap.Message, seq int) { + // go-dap messages embed ProtocolMessage which has Seq. + // We use JSON round-trip to set it generically. This could be done + // more efficiently with a type switch, but send is not hot path. + type hasSeq interface { + GetSeq() int + } + // Use reflection-free approach: marshal, patch, unmarshal is overkill. + // Instead, use the concrete type knowledge: + switch m := msg.(type) { + case *dap.InitializeResponse: + m.Seq = seq + case *dap.LaunchResponse: + m.Seq = seq + case *dap.AttachResponse: + m.Seq = seq + case *dap.SetBreakpointsResponse: + m.Seq = seq + case *dap.SetExceptionBreakpointsResponse: + m.Seq = seq + case *dap.ConfigurationDoneResponse: + m.Seq = seq + case *dap.ContinueResponse: + m.Seq = seq + case *dap.NextResponse: + m.Seq = seq + case *dap.StepInResponse: + m.Seq = seq + case *dap.StepOutResponse: + m.Seq = seq + case *dap.PauseResponse: + m.Seq = seq + case *dap.StackTraceResponse: + m.Seq = seq + case *dap.ScopesResponse: + m.Seq = seq + case *dap.VariablesResponse: + m.Seq = seq + case *dap.EvaluateResponse: + m.Seq = seq + case *dap.SetVariableResponse: + m.Seq = seq + case *dap.ThreadsResponse: + m.Seq = seq + case *dap.DisconnectResponse: + m.Seq = seq + case *dap.TerminateResponse: + m.Seq = seq + case *dap.ErrorResponse: + m.Seq = seq + case *dap.InitializedEvent: + m.Seq = seq + case *dap.StoppedEvent: + m.Seq = seq + case *dap.ContinuedEvent: + m.Seq = seq + case *dap.TerminatedEvent: + m.Seq = seq + case *dap.ThreadEvent: + m.Seq = seq + case *dap.OutputEvent: + m.Seq = seq + case *dap.ExitedEvent: + m.Seq = seq + } +} + +// ServeTCP starts a DAP server listening on the given TCP address. +// It blocks until a single client connects, runs a debug session, then returns. +// The addr parameter is a TCP address (e.g., "127.0.0.1:4711" or ":4711"). +// runFunc is called to start JS execution after the client sends ConfigurationDone. +// +// Example usage: +// +// r := goja.New() +// err := debugger.ServeTCP(r, "127.0.0.1:4711", func() error { +// _, err := r.RunString(script) +// return err +// }) +func ServeTCP(r *goja.Runtime, addr string, runFunc func() error) error { + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("debugger: listen on %s: %w", addr, err) + } + defer ln.Close() + + conn, err := ln.Accept() + if err != nil { + return fmt.Errorf("debugger: accept: %w", err) + } + defer conn.Close() + + srv := NewServer(r, conn, conn, runFunc) + return srv.Run() +} + +// TCPSession represents a running DAP debug session over TCP. +// Use Wait() to block until the session completes. +type TCPSession struct { + // Addr is the address the server is listening on. Useful when the + // port was auto-assigned (addr ":0"). + Addr net.Addr + ln net.Listener + errCh chan error +} + +// Wait blocks until the debug session completes and returns any error. +func (s *TCPSession) Wait() error { + return <-s.errCh +} + +// Close stops the listener, which will cause the session to end if +// no client has connected yet. If a session is in progress, it +// interrupts it. +func (s *TCPSession) Close() error { + return s.ln.Close() +} + +// ListenTCP starts a DAP server listening on the given TCP address in +// the background. It returns immediately with a TCPSession that can be +// used to get the listening address and wait for session completion. +// +// The server accepts a single client connection. Once the client +// disconnects, the session ends. +// +// This is designed for embedding: start the listener, print the port, +// then call session.Wait() to block until debugging is done. +// +// Example: +// +// r := goja.New() +// session, err := debugger.ListenTCP(r, "127.0.0.1:0", func() error { +// _, err := r.RunString(script) +// return err +// }) +// if err != nil { log.Fatal(err) } +// fmt.Printf("Debugger listening on %s\n", session.Addr) +// if err := session.Wait(); err != nil { log.Fatal(err) } +func ListenTCP(r *goja.Runtime, addr string, runFunc func() error) (*TCPSession, error) { + ln, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("debugger: listen on %s: %w", addr, err) + } + + session := &TCPSession{ + Addr: ln.Addr(), + ln: ln, + errCh: make(chan error, 1), + } + + go func() { + defer ln.Close() + for { + conn, err := ln.Accept() + if err != nil { + session.errCh <- fmt.Errorf("debugger: accept: %w", err) + return + } + + // Detect probe connections (e.g. port-readiness checks) by + // peeking at the first byte. Real DAP clients send an + // Initialize request promptly; probes connect and disconnect. + br := bufio.NewReader(conn) + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + if _, err := br.Peek(1); err != nil { + conn.Close() + continue // probe — re-accept + } + conn.SetReadDeadline(time.Time{}) // clear deadline + + srv := NewServer(r, br, conn, runFunc) + session.errCh <- srv.Run() + conn.Close() + return + } + }() + + return session, nil +} + +// Ensure json import is used (for launch args parsing in handlers). +var _ = json.Unmarshal diff --git a/debugger/server_test.go b/debugger/server_test.go new file mode 100644 index 000000000..a5ef40d2a --- /dev/null +++ b/debugger/server_test.go @@ -0,0 +1,994 @@ +package debugger + +import ( + "bufio" + "io" + "net" + "testing" + "time" + + "github.com/dop251/goja" + dap "github.com/google/go-dap" +) + +// testClient wraps a DAP transport for testing. +type testClient struct { + reader *bufio.Reader + writer io.Writer + seq int + t *testing.T +} + +func newTestClient(t *testing.T, reader io.Reader, writer io.Writer) *testClient { + return &testClient{ + reader: bufio.NewReader(reader), + writer: writer, + t: t, + } +} + +func (c *testClient) send(msg dap.Message) { + c.seq++ + setSeq(msg, c.seq) + if err := dap.WriteProtocolMessage(c.writer, msg); err != nil { + c.t.Fatalf("Failed to send DAP message: %v", err) + } +} + +func (c *testClient) read() dap.Message { + msg, err := dap.ReadProtocolMessage(c.reader) + if err != nil { + c.t.Fatalf("Failed to read DAP message: %v", err) + } + return msg +} + +func (c *testClient) expectResponse(command string) dap.Message { + msg := c.read() + if resp, ok := msg.(dap.ResponseMessage); ok { + r := resp.GetResponse() + if r.Command != command { + c.t.Fatalf("Expected %s response, got %s", command, r.Command) + } + if !r.Success { + c.t.Fatalf("Expected success response for %s, got failure: %s", command, r.Message) + } + return msg + } + c.t.Fatalf("Expected response message, got %T", msg) + return nil +} + +func (c *testClient) expectEvent(event string) dap.Message { + msg := c.read() + if evt, ok := msg.(dap.EventMessage); ok { + e := evt.GetEvent() + if e.Event != event { + c.t.Fatalf("Expected %s event, got %s", event, e.Event) + } + return msg + } + c.t.Fatalf("Expected event message, got %T", msg) + return nil +} + +func (c *testClient) initialize() { + c.send(&dap.InitializeRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "initialize", + }, + Arguments: dap.InitializeRequestArguments{ + AdapterID: "goja-test", + }, + }) + c.expectResponse("initialize") + c.expectEvent("initialized") +} + +func (c *testClient) launch(stopOnEntry bool) { + args := "{}" + if stopOnEntry { + args = `{"stopOnEntry": true}` + } + c.send(&dap.LaunchRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "launch", + }, + Arguments: []byte(args), + }) + c.expectResponse("launch") +} + +func (c *testClient) setBreakpoints(path string, lines ...int) *dap.SetBreakpointsResponse { + bps := make([]dap.SourceBreakpoint, len(lines)) + for i, line := range lines { + bps[i] = dap.SourceBreakpoint{Line: line} + } + c.send(&dap.SetBreakpointsRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "setBreakpoints", + }, + Arguments: dap.SetBreakpointsArguments{ + Source: dap.Source{Path: path}, + Breakpoints: bps, + }, + }) + resp := c.expectResponse("setBreakpoints") + return resp.(*dap.SetBreakpointsResponse) +} + +func (c *testClient) configurationDone() { + c.send(&dap.ConfigurationDoneRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "configurationDone", + }, + }) + c.expectResponse("configurationDone") +} + +func (c *testClient) continueExec() { + c.send(&dap.ContinueRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "continue", + }, + Arguments: dap.ContinueArguments{ThreadId: 1}, + }) + c.expectResponse("continue") +} + +func (c *testClient) next() { + c.send(&dap.NextRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "next", + }, + Arguments: dap.NextArguments{ThreadId: 1}, + }) + c.expectResponse("next") +} + +func (c *testClient) stepIn() { + c.send(&dap.StepInRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "stepIn", + }, + Arguments: dap.StepInArguments{ThreadId: 1}, + }) + c.expectResponse("stepIn") +} + +func (c *testClient) stepOut() { + c.send(&dap.StepOutRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "stepOut", + }, + Arguments: dap.StepOutArguments{ThreadId: 1}, + }) + c.expectResponse("stepOut") +} + +func (c *testClient) stackTrace() *dap.StackTraceResponse { + c.send(&dap.StackTraceRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "stackTrace", + }, + Arguments: dap.StackTraceArguments{ThreadId: 1}, + }) + resp := c.expectResponse("stackTrace") + return resp.(*dap.StackTraceResponse) +} + +func (c *testClient) scopes(frameId int) *dap.ScopesResponse { + c.send(&dap.ScopesRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "scopes", + }, + Arguments: dap.ScopesArguments{FrameId: frameId}, + }) + resp := c.expectResponse("scopes") + return resp.(*dap.ScopesResponse) +} + +func (c *testClient) variables(ref int) *dap.VariablesResponse { + c.send(&dap.VariablesRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "variables", + }, + Arguments: dap.VariablesArguments{VariablesReference: ref}, + }) + resp := c.expectResponse("variables") + return resp.(*dap.VariablesResponse) +} + +func (c *testClient) evaluate(expr string, frameId int) *dap.EvaluateResponse { + c.send(&dap.EvaluateRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "evaluate", + }, + Arguments: dap.EvaluateArguments{ + Expression: expr, + FrameId: frameId, + }, + }) + resp := c.expectResponse("evaluate") + return resp.(*dap.EvaluateResponse) +} + +func (c *testClient) setBreakpointsWithOpts(path string, sbps ...dap.SourceBreakpoint) *dap.SetBreakpointsResponse { + c.send(&dap.SetBreakpointsRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "setBreakpoints", + }, + Arguments: dap.SetBreakpointsArguments{ + Source: dap.Source{Path: path}, + Breakpoints: sbps, + }, + }) + resp := c.expectResponse("setBreakpoints") + return resp.(*dap.SetBreakpointsResponse) +} + +func (c *testClient) setExceptionBreakpoints(filters ...string) { + c.send(&dap.SetExceptionBreakpointsRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "setExceptionBreakpoints", + }, + Arguments: dap.SetExceptionBreakpointsArguments{ + Filters: filters, + }, + }) + c.expectResponse("setExceptionBreakpoints") +} + +func (c *testClient) setVariable(ref int, name, value string) *dap.SetVariableResponse { + c.send(&dap.SetVariableRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "setVariable", + }, + Arguments: dap.SetVariableArguments{ + VariablesReference: ref, + Name: name, + Value: value, + }, + }) + resp := c.expectResponse("setVariable") + return resp.(*dap.SetVariableResponse) +} + +func (c *testClient) disconnect() { + c.send(&dap.DisconnectRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "disconnect", + }, + }) + c.expectResponse("disconnect") +} + +func (c *testClient) threads() *dap.ThreadsResponse { + c.send(&dap.ThreadsRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "threads", + }, + }) + resp := c.expectResponse("threads") + return resp.(*dap.ThreadsResponse) +} + +// setupServer creates a server and client connected via pipes. +func setupServer(t *testing.T, script string) (*Server, *testClient) { + clientReader, serverWriter := io.Pipe() + serverReader, clientWriter := io.Pipe() + + r := goja.New() + srv := NewServer(r, serverReader, serverWriter, func() error { + _, err := r.RunString(script) + return err + }) + + client := newTestClient(t, clientReader, clientWriter) + return srv, client +} + +func TestFullSession(t *testing.T) { + srv, client := setupServer(t, "var x = 1;\nvar y = 2;\nvar z = 3;") + + errCh := make(chan error, 1) + go func() { + errCh <- srv.Run() + }() + + // Initialize + client.initialize() + + // Launch + client.launch(false) + + // Set breakpoint on line 2 + bpResp := client.setBreakpoints("", 2) + if len(bpResp.Body.Breakpoints) != 1 { + t.Fatalf("Expected 1 breakpoint, got %d", len(bpResp.Body.Breakpoints)) + } + if !bpResp.Body.Breakpoints[0].Verified { + t.Fatal("Expected breakpoint to be verified") + } + + // Configuration done — starts VM + client.configurationDone() + + // Should hit breakpoint on line 2 + stopped := client.expectEvent("stopped").(*dap.StoppedEvent) + if stopped.Body.Reason != "breakpoint" { + t.Fatalf("Expected reason 'breakpoint', got '%s'", stopped.Body.Reason) + } + + // Threads + threadsResp := client.threads() + if len(threadsResp.Body.Threads) != 1 { + t.Fatalf("Expected 1 thread, got %d", len(threadsResp.Body.Threads)) + } + if threadsResp.Body.Threads[0].Name != "main" { + t.Fatalf("Expected thread name 'main', got '%s'", threadsResp.Body.Threads[0].Name) + } + + // Stack trace + stResp := client.stackTrace() + if len(stResp.Body.StackFrames) == 0 { + t.Fatal("Expected at least 1 stack frame") + } + if stResp.Body.StackFrames[0].Line != 2 { + t.Fatalf("Expected frame at line 2, got line %d", stResp.Body.StackFrames[0].Line) + } + + // Continue + client.continueExec() + + // Should get terminated event + client.expectEvent("terminated") + + // Disconnect + client.disconnect() + + // Server should exit + select { + case err := <-errCh: + if err != nil { + t.Fatalf("Server returned error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Server did not exit in time") + } +} + +func TestStepping(t *testing.T) { + srv, client := setupServer(t, `var a = 1; +var b = 2; +var c = 3; +var d = 4;`) + + go srv.Run() + + client.initialize() + client.launch(false) + client.setBreakpoints("", 1) + client.configurationDone() + + // Hit breakpoint at line 1 + stopped := client.expectEvent("stopped").(*dap.StoppedEvent) + if stopped.Body.Reason != "breakpoint" { + t.Fatalf("Expected 'breakpoint', got '%s'", stopped.Body.Reason) + } + + // Step over → should stop at line 2 + client.next() + stopped = client.expectEvent("stopped").(*dap.StoppedEvent) + if stopped.Body.Reason != "step" { + t.Fatalf("Expected 'step', got '%s'", stopped.Body.Reason) + } + st := client.stackTrace() + if st.Body.StackFrames[0].Line != 2 { + t.Fatalf("Expected line 2 after step over, got %d", st.Body.StackFrames[0].Line) + } + + // Step over again → line 3 + client.next() + client.expectEvent("stopped") + st = client.stackTrace() + if st.Body.StackFrames[0].Line != 3 { + t.Fatalf("Expected line 3, got %d", st.Body.StackFrames[0].Line) + } + + // Continue to end + client.continueExec() + client.expectEvent("terminated") + client.disconnect() +} + +func TestStepInOut(t *testing.T) { + srv, client := setupServer(t, `function foo() { + var inner = 42; + return inner; +} +var result = foo();`) + + go srv.Run() + + client.initialize() + client.launch(false) + client.setBreakpoints("", 5) // Break at call site + client.configurationDone() + + // Hit breakpoint at line 5 + client.expectEvent("stopped") + + // Step in → should enter foo() + client.stepIn() + stopped := client.expectEvent("stopped").(*dap.StoppedEvent) + if stopped.Body.Reason != "step" { + t.Fatalf("Expected 'step', got '%s'", stopped.Body.Reason) + } + st := client.stackTrace() + if len(st.Body.StackFrames) < 2 { + t.Fatalf("Expected at least 2 frames after step-in, got %d", len(st.Body.StackFrames)) + } + + // Step out → should return to call site + client.stepOut() + client.expectEvent("stopped") + + // Continue to end + client.continueExec() + client.expectEvent("terminated") + client.disconnect() +} + +func TestVariableInspection(t *testing.T) { + srv, client := setupServer(t, `var x = 42; +var y = "hello"; +var obj = {a: 1, b: 2}; +debugger;`) + + go srv.Run() + + client.initialize() + client.launch(false) + client.configurationDone() + + // Should hit debugger statement + stopped := client.expectEvent("stopped").(*dap.StoppedEvent) + if stopped.Body.Reason != "breakpoint" { // debugger stmt maps to "breakpoint" + t.Fatalf("Expected 'breakpoint', got '%s'", stopped.Body.Reason) + } + + // Get stack trace + st := client.stackTrace() + if len(st.Body.StackFrames) == 0 { + t.Fatal("Expected at least 1 stack frame") + } + frameId := st.Body.StackFrames[0].Id + + // Get scopes + scopesResp := client.scopes(frameId) + if len(scopesResp.Body.Scopes) == 0 { + t.Fatal("Expected at least 1 scope") + } + + // Get variables from the first scope that has variables + var foundX, foundY, foundObj bool + for _, scope := range scopesResp.Body.Scopes { + varsResp := client.variables(scope.VariablesReference) + for _, v := range varsResp.Body.Variables { + switch v.Name { + case "x": + foundX = true + if v.Value != "42" { + t.Fatalf("Expected x=42, got x=%s", v.Value) + } + case "y": + foundY = true + if v.Value != "hello" { + t.Fatalf("Expected y=hello, got y=%s", v.Value) + } + case "obj": + foundObj = true + if v.VariablesReference == 0 { + t.Fatal("Expected obj to be expandable (variablesReference > 0)") + } + // Expand the object + objVars := client.variables(v.VariablesReference) + var foundA, foundB bool + for _, ov := range objVars.Body.Variables { + if ov.Name == "a" && ov.Value == "1" { + foundA = true + } + if ov.Name == "b" && ov.Value == "2" { + foundB = true + } + } + if !foundA || !foundB { + t.Fatalf("Expected obj to have a=1 and b=2, got %+v", objVars.Body.Variables) + } + } + } + } + + if !foundX || !foundY || !foundObj { + t.Fatalf("Missing variables: x=%v y=%v obj=%v", foundX, foundY, foundObj) + } + + client.continueExec() + client.expectEvent("terminated") + client.disconnect() +} + +func TestEvaluate(t *testing.T) { + srv, client := setupServer(t, `var x = 10; +var y = 20; +debugger;`) + + go srv.Run() + + client.initialize() + client.launch(false) + client.configurationDone() + + // Hit debugger statement + client.expectEvent("stopped") + + st := client.stackTrace() + frameId := st.Body.StackFrames[0].Id + + // Evaluate simple expression + evalResp := client.evaluate("x + y", frameId) + if evalResp.Body.Result != "30" { + t.Fatalf("Expected '30', got '%s'", evalResp.Body.Result) + } + + // Evaluate expression with side effects + evalResp = client.evaluate("x = 100", frameId) + if evalResp.Body.Result != "100" { + t.Fatalf("Expected '100', got '%s'", evalResp.Body.Result) + } + + // Verify side effect persisted + evalResp = client.evaluate("x", frameId) + if evalResp.Body.Result != "100" { + t.Fatalf("Expected x to be 100 after mutation, got '%s'", evalResp.Body.Result) + } + + client.continueExec() + client.expectEvent("terminated") + client.disconnect() +} + +func TestPause(t *testing.T) { + // Use a script with a loop so there's time to pause + srv, client := setupServer(t, `var i = 0; +while (i < 1000000) { + i++; +}`) + + go srv.Run() + + client.initialize() + client.launch(false) + client.configurationDone() + + // Request pause while running + client.send(&dap.PauseRequest{ + Request: dap.Request{ + ProtocolMessage: dap.ProtocolMessage{Type: "request"}, + Command: "pause", + }, + Arguments: dap.PauseArguments{ThreadId: 1}, + }) + client.expectResponse("pause") + + // Should get stopped event with reason "pause" + stopped := client.expectEvent("stopped").(*dap.StoppedEvent) + if stopped.Body.Reason != "pause" { + t.Fatalf("Expected reason 'pause', got '%s'", stopped.Body.Reason) + } + + // Continue to finish + client.continueExec() + client.expectEvent("terminated") + client.disconnect() +} + +func TestDebuggerStatement(t *testing.T) { + srv, client := setupServer(t, `var x = 1; +debugger; +var y = 2;`) + + go srv.Run() + + client.initialize() + client.launch(false) + client.configurationDone() + + // Should hit debugger statement + stopped := client.expectEvent("stopped").(*dap.StoppedEvent) + if stopped.Body.Reason != "breakpoint" { // debugger stmt maps to "breakpoint" + t.Fatalf("Expected 'breakpoint', got '%s'", stopped.Body.Reason) + } + + client.continueExec() + client.expectEvent("terminated") + client.disconnect() +} + +func TestStopOnEntry(t *testing.T) { + srv, client := setupServer(t, `var x = 1; +var y = 2;`) + + go srv.Run() + + client.initialize() + client.launch(true) // stopOnEntry = true + client.configurationDone() + + // Should pause at entry + stopped := client.expectEvent("stopped").(*dap.StoppedEvent) + if stopped.Body.Reason != "pause" { + t.Fatalf("Expected reason 'pause' for stop-on-entry, got '%s'", stopped.Body.Reason) + } + + // Verify we're at line 1 + st := client.stackTrace() + if len(st.Body.StackFrames) > 0 && st.Body.StackFrames[0].Line != 1 { + t.Fatalf("Expected to stop at line 1, got line %d", st.Body.StackFrames[0].Line) + } + + client.continueExec() + client.expectEvent("terminated") + client.disconnect() +} + +func TestDisconnectWhilePaused(t *testing.T) { + srv, client := setupServer(t, `debugger; +var x = 1;`) + + errCh := make(chan error, 1) + go func() { + errCh <- srv.Run() + }() + + client.initialize() + client.launch(false) + client.configurationDone() + + // Hit debugger statement + client.expectEvent("stopped") + + // Disconnect while paused + client.disconnect() + + // Server should exit cleanly + select { + case err := <-errCh: + if err != nil { + t.Fatalf("Server returned error: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Server did not exit in time") + } +} + +func TestMultipleBreakpoints(t *testing.T) { + srv, client := setupServer(t, `var a = 1; +var b = 2; +var c = 3; +var d = 4;`) + + go srv.Run() + + client.initialize() + client.launch(false) + client.setBreakpoints("", 2, 4) + client.configurationDone() + + // First breakpoint at line 2 + stopped := client.expectEvent("stopped").(*dap.StoppedEvent) + if stopped.Body.Reason != "breakpoint" { + t.Fatalf("Expected 'breakpoint', got '%s'", stopped.Body.Reason) + } + st := client.stackTrace() + if st.Body.StackFrames[0].Line != 2 { + t.Fatalf("Expected line 2, got %d", st.Body.StackFrames[0].Line) + } + + // Continue to second breakpoint + client.continueExec() + stopped = client.expectEvent("stopped").(*dap.StoppedEvent) + if stopped.Body.Reason != "breakpoint" { + t.Fatalf("Expected 'breakpoint', got '%s'", stopped.Body.Reason) + } + st = client.stackTrace() + if st.Body.StackFrames[0].Line != 4 { + t.Fatalf("Expected line 4, got %d", st.Body.StackFrames[0].Line) + } + + // Continue to end + client.continueExec() + client.expectEvent("terminated") + client.disconnect() +} + +func TestDAPConditionalBreakpoint(t *testing.T) { + srv, client := setupServer(t, `var x = 0; +for (var i = 0; i < 5; i++) { + x = i; +}`) + + go srv.Run() + + client.initialize() + client.launch(false) + // Set conditional breakpoint: only pause when i == 3 + client.setBreakpointsWithOpts("", dap.SourceBreakpoint{ + Line: 3, + Condition: "i == 3", + }) + client.configurationDone() + + // Should hit breakpoint only when i == 3 + client.expectEvent("stopped") + st := client.stackTrace() + if st.Body.StackFrames[0].Line != 3 { + t.Fatalf("Expected line 3, got %d", st.Body.StackFrames[0].Line) + } + + // Verify i == 3 + evalResp := client.evaluate("i", 1) + if evalResp.Body.Result != "3" { + t.Fatalf("Expected i=3, got i=%s", evalResp.Body.Result) + } + + client.continueExec() + client.expectEvent("terminated") + client.disconnect() +} + +func TestDAPHitCountBreakpoint(t *testing.T) { + srv, client := setupServer(t, `var x = 0; +for (var i = 0; i < 10; i++) { + x = i; +}`) + + go srv.Run() + + client.initialize() + client.launch(false) + // Hit count: pause every 3rd hit + client.setBreakpointsWithOpts("", dap.SourceBreakpoint{ + Line: 3, + HitCondition: "3", + }) + client.configurationDone() + + // First pause: 3rd hit (i=2) + client.expectEvent("stopped") + evalResp := client.evaluate("i", 1) + if evalResp.Body.Result != "2" { + t.Fatalf("Expected i=2 at 3rd hit, got i=%s", evalResp.Body.Result) + } + + // Continue → 6th hit (i=5) + client.continueExec() + client.expectEvent("stopped") + evalResp = client.evaluate("i", 1) + if evalResp.Body.Result != "5" { + t.Fatalf("Expected i=5 at 6th hit, got i=%s", evalResp.Body.Result) + } + + // Continue → 9th hit (i=8) + client.continueExec() + client.expectEvent("stopped") + evalResp = client.evaluate("i", 1) + if evalResp.Body.Result != "8" { + t.Fatalf("Expected i=8 at 9th hit, got i=%s", evalResp.Body.Result) + } + + // Continue to end + client.continueExec() + client.expectEvent("terminated") + client.disconnect() +} + +func TestDAPLogPoint(t *testing.T) { + srv, client := setupServer(t, `var a = 10; +var b = 20; +var c = 30;`) + + go srv.Run() + + client.initialize() + client.launch(false) + // Log point on line 2: should log instead of pausing + client.setBreakpointsWithOpts("", dap.SourceBreakpoint{ + Line: 2, + LogMessage: "value of a is {a}", + }) + client.configurationDone() + + // Should NOT get a stopped event — should get an output event then terminated + msg := client.read() + switch m := msg.(type) { + case *dap.OutputEvent: + if m.Body.Category != "console" { + t.Fatalf("Expected category 'console', got '%s'", m.Body.Category) + } + if m.Body.Output != "value of a is 10\n" { + t.Fatalf("Expected 'value of a is 10\\n', got '%s'", m.Body.Output) + } + case *dap.StoppedEvent: + t.Fatal("Log point should not cause a stopped event") + default: + t.Fatalf("Expected output event, got %T", msg) + } + + client.expectEvent("terminated") + client.disconnect() +} + +func TestDAPExceptionBreakpoints(t *testing.T) { + srv, client := setupServer(t, `var x = 1; +try { + throw new Error("test error"); +} catch(e) { + x = 2; +} +var y = 3;`) + + go srv.Run() + + client.initialize() + client.launch(false) + // Set exception breakpoints for all exceptions + client.setExceptionBreakpoints("all") + client.configurationDone() + + // Should pause on the thrown exception + stopped := client.expectEvent("stopped").(*dap.StoppedEvent) + if stopped.Body.Reason != "exception" { + t.Fatalf("Expected reason 'exception', got '%s'", stopped.Body.Reason) + } + + client.continueExec() + client.expectEvent("terminated") + client.disconnect() +} + +func TestDAPExceptionBreakpointsUncaughtOnly(t *testing.T) { + srv, client := setupServer(t, `var x = 1; +try { + throw new Error("caught"); +} catch(e) { + x = 2; +} +var y = 3;`) + + go srv.Run() + + client.initialize() + client.launch(false) + // Only uncaught exceptions + client.setExceptionBreakpoints("uncaught") + client.configurationDone() + + // The exception is caught, so no stopped event — goes straight to terminated + client.expectEvent("terminated") + client.disconnect() +} + +func TestDAPSetVariable(t *testing.T) { + srv, client := setupServer(t, `var x = 10; +debugger; +var result = x * 2;`) + + go srv.Run() + + client.initialize() + client.launch(false) + client.configurationDone() + + // Hit debugger statement + client.expectEvent("stopped") + + // Get scopes to find the variable reference + st := client.stackTrace() + frameId := st.Body.StackFrames[0].Id + scopesResp := client.scopes(frameId) + + // Find the scope containing x + var scopeRef int + for _, scope := range scopesResp.Body.Scopes { + varsResp := client.variables(scope.VariablesReference) + for _, v := range varsResp.Body.Variables { + if v.Name == "x" && v.Value == "10" { + scopeRef = scope.VariablesReference + break + } + } + if scopeRef != 0 { + break + } + } + if scopeRef == 0 { + t.Fatal("Could not find variable x in any scope") + } + + // Set x to 42 + setResp := client.setVariable(scopeRef, "x", "42") + if setResp.Body.Value != "42" { + t.Fatalf("Expected set value '42', got '%s'", setResp.Body.Value) + } + + // Verify the change via eval + evalResp := client.evaluate("x", frameId) + if evalResp.Body.Result != "42" { + t.Fatalf("Expected x=42 after set, got x=%s", evalResp.Body.Result) + } + + client.continueExec() + client.expectEvent("terminated") + client.disconnect() +} + +func TestListenTCP(t *testing.T) { + r := goja.New() + session, err := ListenTCP(r, "127.0.0.1:0", func() error { + _, err := r.RunString("var x = 1;\ndebugger;\nvar y = 2;") + return err + }) + if err != nil { + t.Fatalf("ListenTCP failed: %v", err) + } + defer session.Close() + + // Connect to the server + conn, err := net.Dial("tcp", session.Addr.String()) + if err != nil { + t.Fatalf("Failed to connect: %v", err) + } + defer conn.Close() + + client := newTestClient(t, conn, conn) + + // Run a minimal debug session + client.initialize() + client.launch(false) + client.configurationDone() + + // Should hit debugger statement + stopped := client.expectEvent("stopped").(*dap.StoppedEvent) + if stopped.Body.Reason != "breakpoint" { + t.Fatalf("Expected 'breakpoint', got '%s'", stopped.Body.Reason) + } + + // Continue to end + client.continueExec() + client.expectEvent("terminated") + client.disconnect() + + // Wait for session to complete + if err := session.Wait(); err != nil { + t.Fatalf("Session error: %v", err) + } +} diff --git a/debugger/vscode-goja-debugger/.vscodeignore b/debugger/vscode-goja-debugger/.vscodeignore new file mode 100644 index 000000000..7120c777d --- /dev/null +++ b/debugger/vscode-goja-debugger/.vscodeignore @@ -0,0 +1,2 @@ +.vscodeignore +*.vsix diff --git a/debugger/vscode-goja-debugger/extension.js b/debugger/vscode-goja-debugger/extension.js new file mode 100644 index 000000000..f8d2807dd --- /dev/null +++ b/debugger/vscode-goja-debugger/extension.js @@ -0,0 +1,173 @@ +const vscode = require('vscode'); +const { spawn } = require('child_process'); +const net = require('net'); + +/** @type {import('child_process').ChildProcess | null} */ +let harnessProcess = null; + +function activate(context) { + const factory = { + async createDebugAdapterDescriptor(session) { + const config = session.configuration; + const port = config.port || 4711; + const host = config.host || '127.0.0.1'; + + if (config.request === 'launch') { + await launchProgram(config, port); + // No waitForPort — launchProgram already waits for "listening" + // on stderr, which guarantees the port is bound. + } else if (config.request === 'attach') { + // For attach (including compound configs where another + // debugger launches the Go program), wait for the DAP + // server to start listening. The probe connection is safe + // because ListenTCP discards probes that don't send data. + await waitForPort(host, port, 30000); + } + + return new vscode.DebugAdapterServer(port, host); + } + }; + + context.subscriptions.push( + vscode.debug.registerDebugAdapterDescriptorFactory('goja', factory) + ); + + context.subscriptions.push( + vscode.debug.onDidTerminateDebugSession(session => { + if (session.configuration.type === 'goja' && harnessProcess) { + harnessProcess.kill(); + harnessProcess = null; + } + }) + ); +} + +/** + * Launch the user's Go program that embeds goja and starts a DAP server. + * + * The launch config should look like: + * { + * "type": "goja", + * "request": "launch", + * "program": "/path/to/go/package", // directory with main.go + * "args": ["--script", "foo.ts"], // any args for the program + * "port": 4711, // DAP port (passed via --port) + * "cwd": "/optional/working/dir", // defaults to program dir + * "env": { "KEY": "VAL" }, // extra env vars + * "buildArgs": ["-tags", "debug"] // extra args for `go run` + * } + * + * The extension runs: go run [buildArgs...] . [--port PORT] [args...] + * It waits for the program to print "listening" on stderr before connecting. + */ +async function launchProgram(config, port) { + if (harnessProcess) { + harnessProcess.kill(); + harnessProcess = null; + } + + const program = config.program; + if (!program) { + throw new Error('goja launch config requires a "program" field pointing to a Go package directory'); + } + + // Build the command: go run [buildArgs...] . [--port PORT] [args...] + const goRunArgs = ['run']; + if (config.buildArgs) { + goRunArgs.push(...config.buildArgs); + } + goRunArgs.push('.'); + goRunArgs.push('--port', String(port)); + if (config.args) { + goRunArgs.push(...config.args); + } + + const cwd = config.cwd || program; + + const output = vscode.window.createOutputChannel('Goja Debugger'); + output.show(true); + output.appendLine(`> cd ${cwd} && go ${goRunArgs.join(' ')}`); + + return new Promise((resolve, reject) => { + const proc = spawn('go', goRunArgs, { + cwd, + env: { ...process.env, ...config.env }, + }); + harnessProcess = proc; + + let resolved = false; + + proc.stderr.on('data', (data) => { + const text = data.toString(); + output.append(text); + if (!resolved && text.includes('listening')) { + resolved = true; + resolve(); + } + }); + + proc.stdout.on('data', (data) => { + output.append(data.toString()); + }); + + proc.on('error', (err) => { + output.appendLine(`Process error: ${err.message}`); + if (!resolved) { + resolved = true; + reject(err); + } + }); + + proc.on('exit', (code) => { + output.appendLine(`Process exited with code ${code}`); + harnessProcess = null; + if (!resolved) { + resolved = true; + if (code !== 0) { + reject(new Error(`Process exited with code ${code}`)); + } else { + resolve(); + } + } + }); + }); +} + +/** + * Wait for a TCP port to accept connections. + * The server's ListenTCP gracefully handles these probe connections + * (they connect but send no data, so the server re-accepts). + */ +function waitForPort(host, port, timeoutMs) { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + + function tryConnect() { + if (Date.now() > deadline) { + reject(new Error(`Timeout waiting for ${host}:${port}`)); + return; + } + const sock = new net.Socket(); + sock.once('connect', () => { + sock.destroy(); + resolve(); + }); + sock.once('error', () => { + sock.destroy(); + setTimeout(tryConnect, 200); + }); + sock.connect(port, host); + } + + tryConnect(); + }); +} + +function deactivate() { + if (harnessProcess) { + harnessProcess.kill(); + harnessProcess = null; + } +} + +module.exports = { activate, deactivate }; diff --git a/debugger/vscode-goja-debugger/package.json b/debugger/vscode-goja-debugger/package.json new file mode 100644 index 000000000..2a9e6177b --- /dev/null +++ b/debugger/vscode-goja-debugger/package.json @@ -0,0 +1,103 @@ +{ + "name": "vscode-goja-debugger", + "displayName": "Goja JavaScript Debugger", + "description": "Debug JavaScript/TypeScript running in any Go application that embeds goja with DAP support", + "version": "0.0.3", + "publisher": "goja", + "engines": { "vscode": "^1.70.0" }, + "categories": ["Debuggers"], + "activationEvents": ["onDebugResolve:goja"], + "main": "./extension.js", + "contributes": { + "breakpoints": [ + { "language": "javascript" }, + { "language": "typescript" } + ], + "debuggers": [ + { + "type": "goja", + "label": "Goja JavaScript", + "configurationAttributes": { + "launch": { + "required": ["program"], + "properties": { + "program": { + "type": "string", + "description": "Path to the Go package directory that embeds goja" + }, + "args": { + "type": "array", + "description": "Arguments passed to the Go program (after --port)", + "items": { "type": "string" }, + "default": [] + }, + "port": { + "type": "number", + "description": "TCP port for the goja DAP server", + "default": 4711 + }, + "host": { + "type": "string", + "description": "Host for the goja DAP server", + "default": "127.0.0.1" + }, + "cwd": { + "type": "string", + "description": "Working directory (defaults to program directory)" + }, + "env": { + "type": "object", + "description": "Extra environment variables", + "additionalProperties": { "type": "string" } + }, + "buildArgs": { + "type": "array", + "description": "Extra arguments for 'go run' (e.g. -tags, -race)", + "items": { "type": "string" }, + "default": [] + } + } + }, + "attach": { + "properties": { + "port": { + "type": "number", + "description": "TCP port of the running goja DAP server", + "default": 4711 + }, + "host": { + "type": "string", + "description": "Host of the running goja DAP server", + "default": "127.0.0.1" + } + } + } + }, + "configurationSnippets": [ + { + "label": "Goja: Launch", + "description": "Build & launch a Go program with goja DAP debugging", + "body": { + "type": "goja", + "request": "launch", + "name": "Debug in goja", + "program": "^\"\\${workspaceFolder}\"", + "args": [], + "port": 4711 + } + }, + { + "label": "Goja: Attach", + "description": "Attach to a running goja DAP server", + "body": { + "type": "goja", + "request": "attach", + "name": "Attach to goja", + "port": 4711 + } + } + ] + } + ] + } +} diff --git a/debugger/vscode-goja-debugger/vscode-goja-debugger-0.0.3.vsix b/debugger/vscode-goja-debugger/vscode-goja-debugger-0.0.3.vsix new file mode 100644 index 0000000000000000000000000000000000000000..7607805fbb29ac32be110575072c8bb20bd593c3 GIT binary patch literal 3950 zcmaJ^XEYpI+Z|m-jc8GVAX?Oj8Vs)7Afm-c^ieX}Fig}Cbr3-iWc290gwX|~PxQ_Z zVeURmF|_vXIq`@ZM=IOqJ?>pW*Yd+oiA^eHG=0RUQB!1o5Q6`_7P(uW)X zsH6q}*Z}~5lmBBU4}^=ShmZ0p~eNAvsU1sTGzd3YG3RJh~HqTa}waqkzTxZopR3_}pN9|1sd2N2Bm=b4ML` zG2CUS%VVKeR#d+L;S7gd2qOfB#XhhSe=-ii_7_|(D8HV~WWwX``3gZn<#f8T^&aTz z%w{6r$u&Vmc|B|H&VssJ@~T~TatX$tFQOaR%<0?RVSS z@vh}v!`ek?eW|SOk1r+BI-?$#`=~>{o{!q5;j4YG4M5zN)cRSfv@+ca6HeLfcG7;5 zpOjZ^gP2C~iVcUr)rKL=W^U;*Y`+mCd7Zx8xUArrpv>5c4bP2rGf->J&Y8FvnVrDa?~e{BPY|SIa|-AU z+M$#W9Z@<9%e>gwtTp$-lt1$yfCf&Nu2&yj?|UgBa!O_NuR*0Kld5 z-BR1r<9t*f+ujd=J0Yy4{9*3FiJBhWGR!)}tnLi8^sfAJBNleF;tIK-9}E5Q{5$%j z7B#&Ev;0p#@u!n}2{(0`_A3SSnc9k}23C&@6U(C8;uj5-7&CW&h*nk?i{g6vDtVY^ zY?YD}T56|R2X2YT!47Xh1VRd)dEp!RHtRW`!(cvL9 zVyiw$HN<~*tSBb=IjU~quAMP+vltqD{_qJ=Gj`UzM<(;^hW$pDcxGAFiIu^dTvH40 zdr`bk{*bDaf|DAkpWZi(9D6_@4%2>KIa5KoKVa7jgwI=P=Kesrl{00S=m{v(UJ%{b zHzsqnj-fw;z%M-p)u(vV<*+UR6~l-<`n5%~@0$H>b^2%hvagk3KQ)&Rp_zTP`fE<7 zywZg@lCP^qpY}tTny&~!OR!=QIv}Ub7zsi0eGa!D_3o^VvWR>jkF!lkg zfXPsK_#&B<=+~|V&6vyFyfqPM)=#&^*ish*#?KE8jWS^a77vir|_3~qLG21 zV%(#A!pXY3%>0mV{?3&~nxXZVbs^Xk2Agc*jueJt39+`3+?YK>CyyY>r{iCZ4X}a6 zH(f0}I+qL+92+vT^h#1%Cszh7&1sd{_Xj*Fe z1@bbmgrCx6F9g~PSlmOJ*NFC(rX(rkELy>;%i?D=xJDyd`i-rp{3^o>{E1`O26LwB ziYp&*YH{*RDHQ!-&?I%NtrzViROWX!4I&USF!@w2{Yy%Tzzgrwu0+xJ2KQB0=e);@ zBOGo%`l89EoZiRgmRqiRQZvy=JwLfT>$rGE--CMAmMah*w9rq1Bz~)P;CPc-s{zNc z5Vcp)KRkdB)n<-=%;ghljPz+}X%fVrs8av{WAp&PKMZ87`~MjDk2Ja>svf{Rmzpn< z+D|2}(Fsl#%dJ__@z^;;6d$Y^$@@XA;@FW8c`GlleWkFEP{XIbz%Wt_shgB+e`mYT z^A%;O#sg+$9`zeX!_OPX->J;cYtcngasH@ErJfS16gsMRAqXGaad()(IqBF^EBiXR z3h2ZqU}fJ~0SY-TKE_r9g+(?AZoHz)UfZlwh% zAwrIX#t2Xbp;MboI`U(D$j=Ru>`H^Cl&{UZZar?)%%sYFlhpcq&%t)tAP2d5a9!zZ z_ZGrC0Sh=AHOCz~{rBRS&1tgQM%uB3p6m~TPY!vO-6$YzCS|d$Y$mBjn?Bkq1s|@v zOUTn&xtQl|jRIp`pIysf*E}oDyKsB-hG$Mwg6}Ba=JB7B84O%-uF4Pnnx3i=`f|gY z!jo%2icf`9L+10mw)Pmy=ldAGwje(;EnGM^q{mpRo0qnVg@!#9fZ>G}@F(QIWRcVY z-1znZi^b~3e88CYympHxM=qi|-S4iySWb}75>_K^LR6HQ-`K~>EJ{&q6oJ!jR#i=I zIkL{&bD*{T#IO(xVS?SZ%gvY?iUIu9{2AwycTVT!_3u-wTCn1*yr4?(s?<5 zaZIb$>+K(qFlI9ItIR{>A~>CnH)8%yf4u2hs9u`aYibC!_{3eh#*mZD*`HqCB5<9;06=E}6AV1(Y2{aVWO;KBNXI3o%5Mx1?5w-+UFD+B%0Z zaDkSp*@CS^OxSh|d4Wm`jSu8oCN@FqIux*Tki#VSN5#X2Z``6fN~wjfAXm)k=U zIDdz6V4$P!Yk6$dKD*19oXv(M%%e~oAuv+;rx3hv5%=)jzLcI;h7Dc$$PVkV%;_T2 zPa&U>kW&bC^bQ$+yH6BFIfHxin=^uwgxf(QG44Z=aFc)dSoI#??)$^CX*-LrV$RBO zsL&?HRA`4wsv44?*8J>d+#5GLIGYt2N9T{g00#40yNMZ{t;Pa;RQxFERBm%(US$u9 zIgTEAFSU0(h|2XWY;U~H?(Sbs6I`BL_g5qCqn-v*AVUj+->}iRUMz|#II|msN}6SWY$pS!q@ZwE$P=KK+d%=CL(26fSlxEep)+bYBzp3#@?Eg=_}k27k4#P!Ld@^Fqa z-;!d71&*>eu&j9wPr-!(%H3BrYW!gno{5xn0V8O=OBqVZ^d^DFU4q`*v$eF^&!&>d z@?@8f9JWPv>Z`dsH>IC`pZsJ8an#Pd796a*ZcXIH6pCiYb4-0Nom=jyu_UT~+v|B^ z2jYBsd;1QPi7~&9^YD#8a%&anDMbk!En-+bVPTWMF{mvagt=!Q*ZD_KX1PUFdIrW*h+UyRk=s00FwQNE;rWa$@7bEj+2OM1 zy}o^c&)Cv>iJ3bIhj!re@rsa=3q3}Gsm{ikUz^;fUtp%Bw19q*%GP-J#}$`t1c@&2 zdaJXwO0#&x-icvt(t6D9K@)pCzn}(_{B93_?a!IjpK7wa#ZwLJuy}gU3zuQLr+m6~ z6Ekq7OcK*C<%Gsc6dj+3MOv0$%1K79LGgcw$-k^DI_%f!AN~A2TK+f2fBXD@Fv`wj z>i^>U{{r~SLjNx9zby1$RT%c-T=4&X#J>#n?-;*K!0%1|8{;C)?-+lho!fFFeX8?s R0RSk^7sGi-(P#X1`wyLsNDKe~ literal 0 HcmV?d00001 diff --git a/debugger_test.go b/debugger_test.go new file mode 100644 index 000000000..e739ffe70 --- /dev/null +++ b/debugger_test.go @@ -0,0 +1,835 @@ +package goja + +import ( + "sync" + "sync/atomic" + "testing" +) + +func TestDebugHookCalled(t *testing.T) { + r := New() + var positions []DebugPosition + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + positions = append(positions, pos) + return DebugStepIn // keep stepping to hit every statement + }) + dbg.SetBreakpoint("", 1, 0) + r.SetDebugger(dbg) + _, err := r.RunString("var x = 1;\nvar y = 2;\nvar z = x + y;") + if err != nil { + t.Fatal(err) + } + if len(positions) < 3 { + t.Fatalf("Expected at least 3 positions, got %d", len(positions)) + } +} + +func TestDebugHookPositions(t *testing.T) { + r := New() + var lines []int + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + lines = append(lines, pos.Line) + return DebugStepIn // keep stepping + }) + dbg.SetBreakpoint("", 1, 0) // trigger first pause + r.SetDebugger(dbg) + _, err := r.RunString("var x = 1;\nvar y = 2;\nvar z = 3;") + if err != nil { + t.Fatal(err) + } + // Deduplicate consecutive lines (breakpoint + step may fire on same line) + var uniqueLines []int + for _, l := range lines { + if len(uniqueLines) == 0 || uniqueLines[len(uniqueLines)-1] != l { + uniqueLines = append(uniqueLines, l) + } + } + // We should see lines 1, 2, 3 + if len(uniqueLines) < 3 { + t.Fatalf("Expected at least 3 unique lines, got %d: %v (raw: %v)", len(uniqueLines), uniqueLines, lines) + } + for i, expected := range []int{1, 2, 3} { + if i < len(uniqueLines) && uniqueLines[i] != expected { + t.Errorf("Position %d: expected line %d, got %d", i, expected, uniqueLines[i]) + } + } +} + +func TestBreakpointHit(t *testing.T) { + r := New() + hitCount := 0 + var hitLine int + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + hitCount++ + hitLine = pos.Line + } + return DebugContinue + }) + dbg.SetBreakpoint("", 2, 0) + r.SetDebugger(dbg) + _, err := r.RunString("var x = 1;\nvar y = 2;\nvar z = 3;") + if err != nil { + t.Fatal(err) + } + if hitCount != 1 { + t.Fatalf("Expected 1 breakpoint hit, got %d", hitCount) + } + if hitLine != 2 { + t.Fatalf("Expected breakpoint at line 2, got line %d", hitLine) + } +} + +func TestMultipleBreakpoints(t *testing.T) { + r := New() + var hitLines []int + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + hitLines = append(hitLines, pos.Line) + } + return DebugContinue + }) + dbg.SetBreakpoint("", 1, 0) + dbg.SetBreakpoint("", 3, 0) + r.SetDebugger(dbg) + _, err := r.RunString("var x = 1;\nvar y = 2;\nvar z = 3;") + if err != nil { + t.Fatal(err) + } + if len(hitLines) != 2 { + t.Fatalf("Expected 2 breakpoint hits, got %d: %v", len(hitLines), hitLines) + } + if hitLines[0] != 1 || hitLines[1] != 3 { + t.Fatalf("Expected breakpoints at lines 1,3 but got %v", hitLines) + } +} + +func TestRemoveBreakpoint(t *testing.T) { + r := New() + hitCount := 0 + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + hitCount++ + } + return DebugContinue + }) + bp := dbg.SetBreakpoint("", 2, 0) + dbg.RemoveBreakpoint(bp.ID) + r.SetDebugger(dbg) + _, err := r.RunString("var x = 1;\nvar y = 2;\nvar z = 3;") + if err != nil { + t.Fatal(err) + } + if hitCount != 0 { + t.Fatalf("Expected 0 breakpoint hits after removal, got %d", hitCount) + } +} + +func TestClearBreakpoints(t *testing.T) { + r := New() + hitCount := 0 + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + hitCount++ + } + return DebugContinue + }) + dbg.SetBreakpoint("", 1, 0) + dbg.SetBreakpoint("", 2, 0) + dbg.SetBreakpoint("", 3, 0) + dbg.ClearBreakpoints("") + r.SetDebugger(dbg) + _, err := r.RunString("var x = 1;\nvar y = 2;\nvar z = 3;") + if err != nil { + t.Fatal(err) + } + if hitCount != 0 { + t.Fatalf("Expected 0 breakpoint hits after clear, got %d", hitCount) + } +} + +func TestStepOver(t *testing.T) { + r := New() + var events []DebugEvent + var lines []int + stepCount := 0 + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + events = append(events, event) + lines = append(lines, pos.Line) + stepCount++ + if stepCount >= 20 { // safety limit + return DebugContinue + } + return DebugStepOver + }) + dbg.SetBreakpoint("", 5, 0) // break at "var x = add(1, 2);" + r.SetDebugger(dbg) + _, err := r.RunString(`function add(a, b) { + var result = a + b; + return result; +} +var x = add(1, 2); +var y = x + 1;`) + if err != nil { + t.Fatal(err) + } + if len(lines) < 2 { + t.Fatalf("Expected at least 2 positions, got %d: %v", len(lines), lines) + } + // First hit is the breakpoint at line 5 + if events[0] != DebugEventBreakpoint { + t.Errorf("Expected first event to be breakpoint, got %d", events[0]) + } + if lines[0] != 5 { + t.Errorf("Expected breakpoint on line 5, got line %d", lines[0]) + } + // Step-over from line 5 (the function call) should NOT enter add() + foundInsideFunction := false + for i := 1; i < len(lines); i++ { + if lines[i] == 2 || lines[i] == 3 { + foundInsideFunction = true + } + } + if foundInsideFunction { + t.Errorf("Step over should not enter function body, but visited lines: %v", lines) + } +} + +func TestStepIn(t *testing.T) { + r := New() + var lines []int + stepCount := 0 + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + lines = append(lines, pos.Line) + stepCount++ + if stepCount >= 20 { // safety limit + return DebugContinue + } + return DebugStepIn + }) + dbg.SetBreakpoint("", 5, 0) // break at "var x = add(1, 2);" + r.SetDebugger(dbg) + _, err := r.RunString(`function add(a, b) { + var result = a + b; + return result; +} +var x = add(1, 2); +var y = x + 1;`) + if err != nil { + t.Fatal(err) + } + // Step in from line 5 should enter the function and visit line 2 + foundInsideFunction := false + for _, line := range lines { + if line == 2 { + foundInsideFunction = true + break + } + } + if !foundInsideFunction { + t.Errorf("Step in should enter function body, but lines were: %v", lines) + } +} + +func TestStepOut(t *testing.T) { + r := New() + var lines []int + stepCount := 0 + insideFunction := false + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + lines = append(lines, pos.Line) + stepCount++ + if stepCount >= 20 { + return DebugContinue + } + // Step in first to enter the function, then step out + if !insideFunction { + if pos.Line == 2 { + insideFunction = true + return DebugStepOut // step out of the function + } + return DebugStepIn + } + return DebugContinue + }) + dbg.SetBreakpoint("", 5, 0) // break at "var x = add(1, 2);" + r.SetDebugger(dbg) + _, err := r.RunString(`function add(a, b) { + var result = a + b; + return result; +} +var x = add(1, 2); +var y = x + 1;`) + if err != nil { + t.Fatal(err) + } + if len(lines) < 3 { + t.Fatalf("Expected at least 3 positions, got %d: %v", len(lines), lines) + } + // Verify we entered the function (line 2) and then came back out + // After step-out, execution returns to the caller (line 5, the call site) + foundLine2 := false + foundAfterReturn := false + for i, line := range lines { + if line == 2 { + foundLine2 = true + } + // After visiting line 2, we should return to line 5 or 6 + if foundLine2 && i > 0 && (line == 5 || line == 6) { + foundAfterReturn = true + } + } + if !foundLine2 { + t.Errorf("Expected to visit line 2 (inside function), lines: %v", lines) + } + if !foundAfterReturn { + t.Errorf("Expected to return to caller after step-out, lines: %v", lines) + } +} + +func TestDebuggerStatement(t *testing.T) { + r := New() + paused := false + var pauseEvent DebugEvent + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + paused = true + pauseEvent = event + return DebugContinue + }) + r.SetDebugger(dbg) + _, err := r.RunString("var x = 1; debugger; var y = 2;") + if err != nil { + t.Fatal(err) + } + if !paused { + t.Fatal("Expected debugger statement to trigger hook") + } + if pauseEvent != DebugEventDebuggerStmt { + t.Errorf("Expected DebugEventDebuggerStmt, got %d", pauseEvent) + } +} + +func TestDebuggerStatementNoDebugger(t *testing.T) { + // When no debugger is attached, debugger statement should be a no-op + r := New() + v, err := r.RunString("var x = 1; debugger; x + 1;") + if err != nil { + t.Fatal(err) + } + if v.ToInteger() != 2 { + t.Fatalf("Expected 2, got %v", v) + } +} + +func TestRequestPause(t *testing.T) { + r := New() + pauseReceived := false + var mu sync.Mutex + + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventPause { + mu.Lock() + pauseReceived = true + mu.Unlock() + } + return DebugContinue + }) + r.SetDebugger(dbg) + + // Pre-request the pause before running + r.RequestPause() + + _, err := r.RunString("var x = 1; var y = 2;") + if err != nil { + t.Fatal(err) + } + + mu.Lock() + defer mu.Unlock() + if !pauseReceived { + t.Fatal("Expected pause event to be received") + } +} + +func TestDebugNoOverheadWhenDisabled(t *testing.T) { + // When no debugger is attached, execution should use the normal loop + r := New() + v, err := r.RunString("var sum = 0; for (var i = 0; i < 1000; i++) { sum += i; } sum;") + if err != nil { + t.Fatal(err) + } + if v.ToInteger() != 499500 { + t.Fatalf("Expected 499500, got %v", v) + } +} + +func TestCallStack(t *testing.T) { + r := New() + var stack []StackFrame + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + stack = ctx.CallStack() + } + return DebugContinue + }) + dbg.SetBreakpoint("", 2, 0) // inside the inner function + r.SetDebugger(dbg) + _, err := r.RunString(`function inner() { + var x = 1; + return x; +} +function outer() { + return inner(); +} +outer();`) + if err != nil { + t.Fatal(err) + } + if len(stack) < 3 { // inner, outer, + t.Fatalf("Expected at least 3 stack frames, got %d", len(stack)) + } + if stack[0].FuncName() != "inner" { + t.Errorf("Expected top frame to be 'inner', got '%s'", stack[0].FuncName()) + } + if stack[1].FuncName() != "outer" { + t.Errorf("Expected second frame to be 'outer', got '%s'", stack[1].FuncName()) + } +} + +func TestDebugScopesGlobal(t *testing.T) { + r := New() + var scopes []DebugScope + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + scopes = ctx.Scopes(0) + } + return DebugContinue + }) + dbg.SetBreakpoint("", 3, 0) + r.SetDebugger(dbg) + _, err := r.RunString("var x = 42;\nvar y = 'hello';\nvar z = x + 1;") + if err != nil { + t.Fatal(err) + } + if len(scopes) == 0 { + t.Fatal("Expected at least one scope") + } + // Look for x in global scope + foundX := false + for _, s := range scopes { + if s.Type == "global" { + for _, v := range s.Variables { + if v.Name == "x" { + foundX = true + if v.Value.ToInteger() != 42 { + t.Errorf("Expected x=42, got %v", v.Value) + } + } + } + } + } + if !foundX { + t.Error("Expected to find 'x' in global scope") + } +} + +func TestDebugEvalGlobalScope(t *testing.T) { + // Test eval works correctly in global scope with multiple variables + r := New() + var evalResult Value + var evalErr error + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + evalResult, evalErr = ctx.Eval(0, "a + b") + } + return DebugContinue + }) + dbg.SetBreakpoint("", 3, 0) + r.SetDebugger(dbg) + _, err := r.RunString("var a = 10;\nvar b = 20;\nvar c = a + b;") + if err != nil { + t.Fatal(err) + } + if evalErr != nil { + t.Fatalf("Eval error: %v", evalErr) + } + if evalResult == nil || evalResult.ToInteger() != 30 { + t.Fatalf("Expected eval result 30, got %v", evalResult) + } +} + +func TestDebugEval(t *testing.T) { + r := New() + var evalResult Value + var evalErr error + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + evalResult, evalErr = ctx.Eval(0, "x + 1") + } + return DebugContinue + }) + dbg.SetBreakpoint("", 2, 0) + r.SetDebugger(dbg) + _, err := r.RunString("var x = 41;\nvar y = 0;") + if err != nil { + t.Fatal(err) + } + if evalErr != nil { + t.Fatalf("Eval error: %v", evalErr) + } + if evalResult == nil || evalResult.ToInteger() != 42 { + t.Fatalf("Expected eval result 42, got %v", evalResult) + } +} + +func TestBreakpointConcurrentModification(t *testing.T) { + r := New() + hitCount := int32(0) + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + atomic.AddInt32(&hitCount, 1) + } + return DebugContinue + }) + r.SetDebugger(dbg) + + // Set breakpoints concurrently while running + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 100; i++ { + bp := dbg.SetBreakpoint("", 1, 0) + dbg.RemoveBreakpoint(bp.ID) + } + }() + + _, err := r.RunString("var sum = 0; for (var i = 0; i < 100; i++) { sum += i; } sum;") + if err != nil { + t.Fatal(err) + } + wg.Wait() +} + +func TestGetBreakpoints(t *testing.T) { + dbg := NewDebugger(nil) + bp1 := dbg.SetBreakpoint("file1.js", 10, 0) + bp2 := dbg.SetBreakpoint("file2.js", 20, 5) + + bps := dbg.GetBreakpoints() + if len(bps) != 2 { + t.Fatalf("Expected 2 breakpoints, got %d", len(bps)) + } + + dbg.RemoveBreakpoint(bp1.ID) + bps = dbg.GetBreakpoints() + if len(bps) != 1 { + t.Fatalf("Expected 1 breakpoint, got %d", len(bps)) + } + if bps[0].ID != bp2.ID { + t.Errorf("Expected remaining breakpoint ID %d, got %d", bp2.ID, bps[0].ID) + } +} + +func TestBreakpointInLoop(t *testing.T) { + r := New() + hitCount := 0 + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + hitCount++ + } + return DebugContinue + }) + dbg.SetBreakpoint("", 2, 0) // inside the loop body + r.SetDebugger(dbg) + _, err := r.RunString("for (var i = 0; i < 5; i++) {\n var x = i;\n}") + if err != nil { + t.Fatal(err) + } + // The breakpoint on line 2 may fire multiple times per iteration due to + // statement boundary detection granularity. Verify it fires at least 5 times. + if hitCount < 5 { + t.Fatalf("Expected at least 5 breakpoint hits in loop, got %d", hitCount) + } +} + +// Phase 5 tests + +func TestConditionalBreakpoint(t *testing.T) { + r := New() + var hitValues []int64 + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + val, _ := ctx.Eval(0, "i") + hitValues = append(hitValues, val.ToInteger()) + } + return DebugContinue + }) + dbg.SetBreakpoint("", 2, 0, WithCondition("i > 2")) + r.SetDebugger(dbg) + _, err := r.RunString("for (var i = 0; i < 5; i++) {\n var x = i;\n}") + if err != nil { + t.Fatal(err) + } + // Should only hit when i > 2 (i.e., i=3, i=4) + if len(hitValues) != 2 { + t.Fatalf("Expected 2 conditional hits, got %d: %v", len(hitValues), hitValues) + } + if hitValues[0] != 3 || hitValues[1] != 4 { + t.Fatalf("Expected hits at i=3,4 but got %v", hitValues) + } +} + +func TestHitCountBreakpoint(t *testing.T) { + r := New() + hitCount := 0 + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + hitCount++ + } + return DebugContinue + }) + // Hit every 3rd time + dbg.SetBreakpoint("", 2, 0, WithHitCondition("3")) + r.SetDebugger(dbg) + _, err := r.RunString("for (var i = 0; i < 9; i++) {\n var x = i;\n}") + if err != nil { + t.Fatal(err) + } + // 9 iterations, hit every 3rd: hits 3, 6, 9 → 3 pauses + if hitCount != 3 { + t.Fatalf("Expected 3 hit-count breakpoint hits, got %d", hitCount) + } +} + +func TestHitCountComparison(t *testing.T) { + r := New() + hitCount := 0 + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + hitCount++ + } + return DebugContinue + }) + // Pause only after 3rd hit (>= 3) + dbg.SetBreakpoint("", 2, 0, WithHitCondition(">=3")) + r.SetDebugger(dbg) + _, err := r.RunString("for (var i = 0; i < 5; i++) {\n var x = i;\n}") + if err != nil { + t.Fatal(err) + } + // Hits: 1(no), 2(no), 3(yes), 4(yes), 5(yes) → 3 pauses + if hitCount != 3 { + t.Fatalf("Expected 3 hits for >=3, got %d", hitCount) + } +} + +func TestHitCountEquals(t *testing.T) { + r := New() + hitCount := 0 + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + hitCount++ + } + return DebugContinue + }) + // Pause only on exactly the 3rd hit + dbg.SetBreakpoint("", 2, 0, WithHitCondition("==3")) + r.SetDebugger(dbg) + _, err := r.RunString("for (var i = 0; i < 5; i++) {\n var x = i;\n}") + if err != nil { + t.Fatal(err) + } + if hitCount != 1 { + t.Fatalf("Expected 1 hit for ==3, got %d", hitCount) + } +} + +func TestLogPoint(t *testing.T) { + r := New() + var logMessages []string + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + // Hook should NOT be called for log points + t.Fatalf("Debug hook should not fire for log points, got event %d", event) + return DebugContinue + }) + dbg.SetLogHook(func(message string, pos DebugPosition) { + logMessages = append(logMessages, message) + }) + dbg.SetBreakpoint("", 2, 0, WithLogMessage("i = {i}")) + r.SetDebugger(dbg) + _, err := r.RunString("for (var i = 0; i < 3; i++) {\n var x = i;\n}") + if err != nil { + t.Fatal(err) + } + if len(logMessages) != 3 { + t.Fatalf("Expected 3 log messages, got %d: %v", len(logMessages), logMessages) + } + expected := []string{"i = 0", "i = 1", "i = 2"} + for i, msg := range logMessages { + if msg != expected[i] { + t.Errorf("Log message %d: expected %q, got %q", i, expected[i], msg) + } + } +} + +func TestLogPointWithCondition(t *testing.T) { + r := New() + var logMessages []string + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + t.Fatalf("Debug hook should not fire for log points") + return DebugContinue + }) + dbg.SetLogHook(func(message string, pos DebugPosition) { + logMessages = append(logMessages, message) + }) + // Log point with condition: only log when i > 1 + dbg.SetBreakpoint("", 2, 0, WithLogMessage("value: {i}"), WithCondition("i > 1")) + r.SetDebugger(dbg) + _, err := r.RunString("for (var i = 0; i < 4; i++) {\n var x = i;\n}") + if err != nil { + t.Fatal(err) + } + if len(logMessages) != 2 { + t.Fatalf("Expected 2 log messages, got %d: %v", len(logMessages), logMessages) + } +} + +func TestExceptionBreakpointAll(t *testing.T) { + r := New() + var exceptionEvents []DebugEvent + var exceptionValues []string + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + exceptionEvents = append(exceptionEvents, event) + if event == DebugEventException { + if ex := ctx.Exception(); ex != nil { + exceptionValues = append(exceptionValues, ex.Value().String()) + } + } + return DebugContinue + }) + dbg.SetExceptionBreakpoints([]string{"all"}) + r.SetDebugger(dbg) + _, err := r.RunString(` +try { + throw new Error("caught error"); +} catch(e) {} +`) + if err != nil { + t.Fatal(err) + } + // "all" filter should catch the exception even though it's caught + foundException := false + for _, ev := range exceptionEvents { + if ev == DebugEventException { + foundException = true + } + } + if !foundException { + t.Fatalf("Expected exception event with 'all' filter, got events: %v", exceptionEvents) + } + if len(exceptionValues) == 0 { + t.Fatal("Expected exception value to be available") + } +} + +func TestExceptionBreakpointUncaught(t *testing.T) { + r := New() + exceptionCount := 0 + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventException { + exceptionCount++ + } + return DebugContinue + }) + dbg.SetExceptionBreakpoints([]string{"uncaught"}) + r.SetDebugger(dbg) + + // This exception is caught — should NOT trigger + _, _ = r.RunString(` +try { + throw new Error("caught"); +} catch(e) {} +`) + if exceptionCount != 0 { + t.Fatalf("Expected 0 exception events for caught exception with 'uncaught' filter, got %d", exceptionCount) + } +} + +func TestExceptionBreakpointUncaughtFires(t *testing.T) { + r := New() + exceptionCount := 0 + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventException { + exceptionCount++ + } + return DebugContinue + }) + dbg.SetExceptionBreakpoints([]string{"uncaught"}) + r.SetDebugger(dbg) + + // This exception is uncaught — should trigger + _, err := r.RunString(`throw new Error("uncaught");`) + if err == nil { + t.Fatal("Expected error from uncaught exception") + } + if exceptionCount != 1 { + t.Fatalf("Expected 1 exception event for uncaught exception, got %d", exceptionCount) + } +} + +func TestSetVariable(t *testing.T) { + r := New() + var result Value + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + // Set x to 100 + err := ctx.SetVariable(0, 0, "x", r.ToValue(100)) + if err != nil { + t.Fatalf("SetVariable error: %v", err) + } + } + return DebugContinue + }) + dbg.SetBreakpoint("", 2, 0) + r.SetDebugger(dbg) + v, err := r.RunString("var x = 42;\nvar y = x + 1;\ny;") + if err != nil { + t.Fatal(err) + } + result = v + // After setting x=100 at line 2, y = x + 1 = 101 + if result.ToInteger() != 101 { + t.Fatalf("Expected 101 after SetVariable, got %v", result) + } +} + +func TestEvalHitCondition(t *testing.T) { + tests := []struct { + expr string + hitCount int + expected bool + }{ + {"3", 3, true}, + {"3", 2, false}, + {"3", 6, true}, + {">5", 6, true}, + {">5", 5, false}, + {">=5", 5, true}, + {"<3", 2, true}, + {"<3", 3, false}, + {"<=3", 3, true}, + {"==3", 3, true}, + {"==3", 4, false}, + {"!=3", 4, true}, + {"!=3", 3, false}, + {"", 1, true}, + {"invalid", 1, true}, + } + for _, tt := range tests { + result := evalHitCondition(tt.expr, tt.hitCount) + if result != tt.expected { + t.Errorf("evalHitCondition(%q, %d) = %v, want %v", tt.expr, tt.hitCount, result, tt.expected) + } + } +} diff --git a/runtime.go b/runtime.go index eadb8b302..92f076013 100644 --- a/runtime.go +++ b/runtime.go @@ -13,6 +13,7 @@ import ( "reflect" "runtime" "strconv" + "sync/atomic" "time" "golang.org/x/text/collate" @@ -1307,14 +1308,28 @@ func New() *Runtime { // method. This representation is not linked to a runtime in any way and can be run in multiple runtimes (possibly // at the same time). func Compile(name, src string, strict bool) (*Program, error) { - return compile(name, src, strict, true, nil) + return compile(name, src, strict, true, false, nil) +} + +// CompileForDebug is like Compile but also generates debug variable maps. +// Programs compiled with this function allow the debugger to inspect +// stack-register variables (let/const in optimized scopes, function arguments). +// Use this when attaching a Debugger to the runtime. +func CompileForDebug(name, src string, strict bool) (*Program, error) { + return compile(name, src, strict, true, true, nil) } // CompileAST creates an internal representation of the JavaScript code that can be later run using the Runtime.RunProgram() // method. This representation is not linked to a runtime in any way and can be run in multiple runtimes (possibly // at the same time). func CompileAST(prg *js_ast.Program, strict bool) (*Program, error) { - return compileAST(prg, strict, true, nil) + return compileAST(prg, strict, true, false, nil) +} + +// CompileASTForDebug is like CompileAST but also generates debug variable maps. +// See CompileForDebug for details. +func CompileASTForDebug(prg *js_ast.Program, strict bool) (*Program, error) { + return compileAST(prg, strict, true, true, nil) } // MustCompile is like Compile but panics if the code cannot be compiled. @@ -1350,17 +1365,18 @@ func Parse(name, src string, options ...parser.Option) (prg *js_ast.Program, err return } -func compile(name, src string, strict, inGlobal bool, evalVm *vm, parserOptions ...parser.Option) (p *Program, err error) { +func compile(name, src string, strict, inGlobal, debugMode bool, evalVm *vm, parserOptions ...parser.Option) (p *Program, err error) { prg, err := Parse(name, src, parserOptions...) if err != nil { return } - return compileAST(prg, strict, inGlobal, evalVm) + return compileAST(prg, strict, inGlobal, debugMode, evalVm) } -func compileAST(prg *js_ast.Program, strict, inGlobal bool, evalVm *vm) (p *Program, err error) { +func compileAST(prg *js_ast.Program, strict, inGlobal, debugMode bool, evalVm *vm) (p *Program, err error) { c := newCompiler() + c.debugMode = debugMode defer func() { if x := recover(); x != nil { @@ -1380,7 +1396,7 @@ func compileAST(prg *js_ast.Program, strict, inGlobal bool, evalVm *vm) (p *Prog } func (r *Runtime) compile(name, src string, strict, inGlobal bool, evalVm *vm) (p *Program, err error) { - p, err = compile(name, src, strict, inGlobal, evalVm, r.parserOptions...) + p, err = compile(name, src, strict, inGlobal, r.vm.dbg != nil, evalVm, r.parserOptions...) if err != nil { switch x1 := err.(type) { case *CompilerSyntaxError: @@ -1529,6 +1545,33 @@ func (r *Runtime) ClearInterrupt() { r.vm.ClearInterrupt() } +// Compile creates an internal representation of the JavaScript code, automatically +// including debug variable maps when a Debugger is attached to this runtime. +// This is the preferred compile method when a program will be run on a specific +// runtime that may have a debugger, as opposed to the package-level Compile() +// which has no runtime context. +func (r *Runtime) Compile(name, src string, strict bool) (*Program, error) { + return compile(name, src, strict, true, r.vm.dbg != nil, nil, r.parserOptions...) +} + +// SetDebugger attaches or detaches a debugger to/from the runtime. +// When attached, the VM uses a debug-aware execution loop that checks for +// breakpoints and stepping at statement boundaries. +// Pass nil to disable debugging. This must be called before starting execution +// or while the VM is paused in a debug hook. +func (r *Runtime) SetDebugger(dbg *Debugger) { + r.vm.dbg = dbg +} + +// RequestPause requests the runtime to pause at the next statement boundary. +// This is safe to call from any goroutine. +// The pause will be reported as a DebugEventPause to the debug hook. +func (r *Runtime) RequestPause() { + if dbg := r.vm.dbg; dbg != nil { + atomic.StoreUint32(&dbg.pauseRequested, 1) + } +} + /* ToValue converts a Go value into a JavaScript value of a most appropriate type. Structural types (such as structs, maps and slices) are wrapped so that changes are reflected on the original value which can be retrieved using Value.Export(). diff --git a/vm.go b/vm.go index a4c3414cd..3fc3252c1 100644 --- a/vm.go +++ b/vm.go @@ -383,6 +383,13 @@ type vm struct { curAsyncRunner *asyncRunner profTracker *profTracker + + dbg *Debugger // nil when not debugging — zero overhead + + // Debug scope tracking: active block/function scopes with stack-register variables. + // Only populated when dbg != nil. Each enterBlock/enterFunc with stack vars + // pushes an entry; corresponding leaveBlock/return pops it. + dbgScopes []dbgScopeInfo } type instruction interface { @@ -611,6 +618,10 @@ func (vm *vm) halted() bool { } func (vm *vm) run() { + if vm.dbg != nil { + vm.runWithDebugger() + return + } if vm.profTracker != nil && !vm.runWithProfiler() { return } @@ -799,6 +810,11 @@ func (vm *vm) restoreStacks(iterLen, refLen uint32) (ex *Exception) { func (vm *vm) handleThrow(arg interface{}) *Exception { ex := vm.exceptionFromValue(arg) + + if vm.dbg != nil && ex != nil && vm.dbg.HasExceptionBreakpoints() { + vm.dbgCheckException(ex) + } + for len(vm.tryStack) > 0 { tf := &vm.tryStack[len(vm.tryStack)-1] if tf.catchPos == -1 && tf.finallyPos == -1 || ex == nil && tf.catchPos != tryPanicMarker { @@ -810,6 +826,9 @@ func (vm *vm) handleThrow(arg interface{}) *Exception { ctx := &vm.callStack[tf.callStackLen] vm.prg, vm.newTarget, vm.result, vm.pc, vm.sb, vm.args = ctx.prg, ctx.newTarget, ctx.result, ctx.pc, ctx.sb, ctx.args + if vm.dbg != nil { + vm.dbgUnwind(int(tf.callStackLen)) + } vm.callStack = vm.callStack[:tf.callStackLen] } vm.sp = int(tf.sp) @@ -906,6 +925,9 @@ func (vm *vm) peek() Value { func (vm *vm) saveCtx(ctx *context) { ctx.prg, ctx.stash, ctx.privEnv, ctx.newTarget, ctx.result, ctx.pc, ctx.sb, ctx.args = vm.prg, vm.stash, vm.privEnv, vm.newTarget, vm.result, vm.pc, vm.sb, vm.args + if vm.dbg != nil { + vm.dbgSaveCtx() + } } func (vm *vm) pushCtx() { @@ -922,6 +944,9 @@ func (vm *vm) pushCtx() { func (vm *vm) restoreCtx(ctx *context) { vm.prg, vm.stash, vm.privEnv, vm.newTarget, vm.result, vm.pc, vm.sb, vm.args = ctx.prg, ctx.stash, ctx.privEnv, ctx.newTarget, ctx.result, ctx.pc, ctx.sb, ctx.args + if vm.dbg != nil { + vm.dbgRestoreCtx() + } } func (vm *vm) popCtx() { @@ -3655,6 +3680,8 @@ type enterBlock struct { names map[unistring.String]uint32 stashSize uint32 stackSize uint32 + + dbgNames map[unistring.String]int // debug: stack var name → offset (only when debugger may attach) } func (e *enterBlock) exec(vm *vm) { @@ -3665,6 +3692,9 @@ func (e *enterBlock) exec(vm *vm) { vm.stash.names = e.names } } + if vm.dbg != nil && len(e.dbgNames) > 0 { + vm.dbgPushBlockScope(e.dbgNames, vm.sp) + } ss := int(e.stackSize) vm.stack.expand(vm.sp + ss - 1) vv := vm.stack[vm.sp : vm.sp+ss] @@ -3702,6 +3732,7 @@ func (e *enterCatchBlock) exec(vm *vm) { type leaveBlock struct { stackSize uint32 popStash bool + dbgPop bool // pop debug scope entry } func (l *leaveBlock) exec(vm *vm) { @@ -3711,6 +3742,9 @@ func (l *leaveBlock) exec(vm *vm) { if ss := l.stackSize; ss > 0 { vm.sp -= int(ss) } + if vm.dbg != nil && l.dbgPop { + vm.dbgPopScope() + } vm.pc++ } @@ -3722,6 +3756,8 @@ type enterFunc struct { funcType funcType argsToStash bool extensible bool + + dbgNames map[unistring.String]int // debug: stack var name → offset } func (e *enterFunc) exec(vm *vm) { @@ -3792,6 +3828,9 @@ func (e *enterFunc) exec(vm *vm) { vv[i] = nil } vm.sp = sp + ss + if vm.dbg != nil && len(e.dbgNames) > 0 { + vm.dbgPushFuncScope(e.dbgNames, vm.sb, vm.args) + } vm.pc++ } @@ -3914,6 +3953,8 @@ func (c cret) exec(vm *vm) { type enterFuncStashless struct { stackSize uint32 args uint32 + + dbgNames map[unistring.String]int // debug: stack var name → offset for loadStack index } func (e *enterFuncStashless) exec(vm *vm) { @@ -3944,6 +3985,9 @@ func (e *enterFuncStashless) exec(vm *vm) { vm.sp = ss } } + if vm.dbg != nil && len(e.dbgNames) > 0 { + vm.dbgPushFuncScope(e.dbgNames, vm.sb, vm.args) + } vm.pc++ } diff --git a/vm_debug.go b/vm_debug.go new file mode 100644 index 000000000..9a6a15776 --- /dev/null +++ b/vm_debug.go @@ -0,0 +1,222 @@ +package goja + +import ( + "sync/atomic" + + "github.com/dop251/goja/unistring" +) + +// dbgScopeInfo tracks an active scope's stack-register variables for the debugger. +// Each variable maps to an absolute index in vm.stack, computed at scope entry time. +type dbgScopeInfo struct { + vars map[unistring.String]int // variable name → absolute stack index +} + +// debuggerInstr implements the JS `debugger` statement. +// When a Debugger is attached it invokes the debug hook; otherwise it is a no-op. +type debuggerInstr struct{} + +func (debuggerInstr) exec(vm *vm) { + if vm.dbg != nil && vm.dbg.hook != nil { + vm.dbg.invokeHook(vm, DebugEventDebuggerStmt) + } + vm.pc++ +} + +// currentPosition resolves the VM's current program counter to a source position. +func (vm *vm) currentPosition() DebugPosition { + if vm.prg == nil || vm.prg.src == nil { + return DebugPosition{} + } + pos := vm.prg.src.Position(vm.prg.sourceOffset(vm.pc)) + return DebugPosition{ + Filename: pos.Filename, + Line: pos.Line, + Column: pos.Column, + } +} + +// runWithDebugger is the debug-aware execution loop, dispatched from run() +// when a Debugger is attached. It checks for breakpoints and stepping at +// statement boundaries. +func (vm *vm) runWithDebugger() { + dbg := vm.dbg + interrupted := false + for { + if interrupted = atomic.LoadUint32(&vm.interrupted) != 0; interrupted { + break + } + + // Check for user-initiated pause request + if atomic.CompareAndSwapUint32(&dbg.pauseRequested, 1, 0) { + dbg.invokeHook(vm, DebugEventPause) + } + + pc := vm.pc + if pc < 0 || pc >= len(vm.prg.code) { + break + } + + // Execute function/block entry instructions before boundary detection + // so that debug scopes are initialized before the debugger can pause. + // Without this, stepping into a function would stop at the declaration + // line with locals not yet visible. + switch vm.prg.code[pc].(type) { + case *enterFuncStashless, *enterFunc, *enterFuncBody: + vm.prg.code[pc].exec(vm) + continue + } + + // Statement boundary detection: check if source position changed + if vm.prg != nil && vm.prg.src != nil { + srcOffset := vm.prg.sourceOffset(pc) + prgChanged := vm.prg != dbg.lastPrg + if srcOffset != dbg.lastSrcOffset || prgChanged { + dbg.lastSrcOffset = srcOffset + dbg.lastPrg = vm.prg + // Resolve actual line number to avoid firing multiple times + // for the same statement (which can have multiple srcMap entries) + line := vm.prg.src.Position(srcOffset).Line + if line != dbg.lastLine || prgChanged { + dbg.lastLine = line + // At a new statement — check breakpoints and stepping + if event, bp, shouldPause := dbg.shouldPause(vm); shouldPause { + dbg.invokeHook(vm, event) + } else if bp != nil && bp.LogMessage != "" { + // Log point: evaluate message and notify, don't pause + msg := dbg.evalLogMessage(vm, bp.LogMessage) + if dbg.logHook != nil { + dbg.logHook(msg, vm.currentPosition()) + } + } + } + } + } + + vm.prg.code[pc].exec(vm) + } + + if interrupted { + vm.interruptLock.Lock() + v := &InterruptedError{ + iface: vm.interruptVal, + } + v.stack = vm.captureStack(nil, 0) + vm.interruptLock.Unlock() + panic(v) + } +} + +// exceptionIsCaught checks if any try frame on the stack has a catch handler. +func (vm *vm) exceptionIsCaught() bool { + for i := len(vm.tryStack) - 1; i >= 0; i-- { + tf := &vm.tryStack[i] + if tf.catchPos >= 0 && tf.catchPos != tryPanicMarker { + return true + } + } + return false +} + +// dbgSaveCtx pushes a debug frame onto the debugger's parallel call stack. +func (vm *vm) dbgSaveCtx() { + dbg := vm.dbg + dbg.frames = append(dbg.frames, debugFrame{ + scopeLen: len(vm.dbgScopes), + lastLine: dbg.lastLine, + lastOffset: dbg.lastSrcOffset, + lastPrg: dbg.lastPrg, + }) +} + +// dbgRestoreCtx pops the topmost debug frame and restores state. +func (vm *vm) dbgRestoreCtx() { + dbg := vm.dbg + n := len(dbg.frames) + if n == 0 { + return + } + f := dbg.frames[n-1] + dbg.frames = dbg.frames[:n-1] + + vm.dbgScopes = vm.dbgScopes[:f.scopeLen] + if dbg.stepAction == DebugContinue { + // Restore debug tracking to prevent breakpoint double-fire + // when returning to the same line (e.g., after a function call + // on a line with a conditional breakpoint). + dbg.lastLine = f.lastLine + dbg.lastSrcOffset = f.lastOffset + dbg.lastPrg = f.lastPrg + } else { + // During stepping, force statement boundary detection so + // step-over/step-out correctly pause after returning from a call. + dbg.lastLine = -1 + dbg.lastSrcOffset = -1 + dbg.lastPrg = nil + } +} + +// dbgUnwind truncates the debugger's frame stack and scope stack to match a +// call stack unwound to callStackLen (used during exception propagation). +func (vm *vm) dbgUnwind(callStackLen int) { + dbg := vm.dbg + if callStackLen < len(dbg.frames) { + scopeLen := 0 + if callStackLen > 0 { + scopeLen = dbg.frames[callStackLen-1].scopeLen + } + dbg.frames = dbg.frames[:callStackLen] + if scopeLen < len(vm.dbgScopes) { + vm.dbgScopes = vm.dbgScopes[:scopeLen] + } + } +} + +// dbgCheckException checks exception breakpoint conditions and fires the debug +// hook if appropriate. Called from handleThrow. +func (vm *vm) dbgCheckException(ex *Exception) { + dbg := vm.dbg + // Deduplicate: handleThrow can be called multiple times for the same + // exception during propagation (e.g., from _throw.exec and vm.try recovery). + if ex != dbg.lastException { + caught := vm.exceptionIsCaught() + if dbg.exFilterAll || (dbg.exFilterUncaught && !caught) { + dbg.lastException = ex + dbg.currentException = ex + dbg.invokeHook(vm, DebugEventException) + dbg.currentException = nil + } + } +} + +// dbgPushBlockScope pushes debug scope info for a block's stack-register variables. +func (vm *vm) dbgPushBlockScope(dbgNames map[unistring.String]int, sp int) { + vars := make(map[unistring.String]int, len(dbgNames)) + for name, offset := range dbgNames { + vars[name] = sp + offset + } + vm.dbgScopes = append(vm.dbgScopes, dbgScopeInfo{vars: vars}) +} + +// dbgPushFuncScope pushes debug scope info for a function's stack-register variables. +// Arguments are encoded with negative offsets: -(argIdx+1). +func (vm *vm) dbgPushFuncScope(dbgNames map[unistring.String]int, sb, args int) { + vars := make(map[unistring.String]int, len(dbgNames)) + for name, offset := range dbgNames { + if offset < 0 { + // Arg: -(argIdx+1), actual position = sb + 1 + argIdx + vars[name] = sb + 1 + (-offset - 1) + } else { + // Local: absolute position = sb + args + 1 + offset + vars[name] = sb + args + 1 + offset + } + } + vm.dbgScopes = append(vm.dbgScopes, dbgScopeInfo{vars: vars}) +} + +// dbgPopScope pops the topmost debug scope entry. +func (vm *vm) dbgPopScope() { + if n := len(vm.dbgScopes); n > 0 { + vm.dbgScopes = vm.dbgScopes[:n-1] + } +} From 09f5ebe09da707c9e32f619660fe814b3967aaa8 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Mon, 23 Feb 2026 09:05:01 +1000 Subject: [PATCH 2/5] fix --- debugger/server.go | 74 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/debugger/server.go b/debugger/server.go index c47c75db9..8d8473fbe 100644 --- a/debugger/server.go +++ b/debugger/server.go @@ -109,9 +109,14 @@ func (s *Server) Run() error { // Detach debugger only after the VM goroutine has finished, // to avoid racing with VM goroutine reads of vm.dbg. + // Also respect disconnectCh so we don't block if the client + // disconnects before runFunc completes. defer func() { if s.configured && !s.vmDone { - <-s.doneCh + select { + case <-s.doneCh: + case <-s.disconnectCh: + } } s.runtime.SetDebugger(nil) }() @@ -450,5 +455,72 @@ func ListenTCP(r *goja.Runtime, addr string, runFunc func() error) (*TCPSession, return session, nil } +// AttachSession is a debug session where the debugger attaches to an existing +// runtime. Unlike ListenTCP/ServeTCP (which own the JS execution via runFunc), +// AttachSession lets the caller manage execution separately — the debugger +// intercepts all JS execution on the runtime until the session ends. +type AttachSession struct { + *TCPSession + readyCh chan struct{} + closeCh chan struct{} + closeOnce sync.Once +} + +// Ready blocks until a DAP client has connected and sent ConfigurationDone +// (i.e., breakpoints are set and the runtime is ready for JS execution). +func (s *AttachSession) Ready() { + <-s.readyCh +} + +// Close signals the debug session to terminate and waits for cleanup. +// It is safe to call multiple times. +func (s *AttachSession) Close() error { + s.closeOnce.Do(func() { close(s.closeCh) }) + return s.TCPSession.Wait() +} + +// AttachTCP starts a DAP debug server that attaches to an existing runtime. +// Unlike ListenTCP, there is no runFunc — the caller manages JS execution +// separately (e.g., via runtime.RunString). The debugger intercepts all JS +// execution on the runtime until the session ends. +// +// Call session.Ready() to block until a client connects and configures +// breakpoints, then execute JS normally. Call session.Close() when done. +// +// Example: +// +// r := goja.New() +// session, err := debugger.AttachTCP(r, "127.0.0.1:0") +// if err != nil { log.Fatal(err) } +// fmt.Printf("Debugger on %s\n", session.Addr) +// session.Ready() // wait for VS Code to connect +// r.RunString(script) // breakpoints work +// session.Close() +func AttachTCP(r *goja.Runtime, addr string) (*AttachSession, error) { + as := &AttachSession{ + readyCh: make(chan struct{}), + closeCh: make(chan struct{}), + } + + session, err := ListenTCP(r, addr, func() error { + close(as.readyCh) + <-as.closeCh + return nil + }) + if err != nil { + return nil, err + } + as.TCPSession = session + + // If the session ends before Close() (e.g., client disconnect), + // unblock the runFunc goroutine to prevent a leak. + go func() { + session.Wait() + as.closeOnce.Do(func() { close(as.closeCh) }) + }() + + return as, nil +} + // Ensure json import is used (for launch args parsing in handlers). var _ = json.Unmarshal From b58652ee6ccc04a8d293c71b372d87b780c3363a Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Mon, 23 Feb 2026 16:45:33 +1000 Subject: [PATCH 3/5] fix: fixed block scope variables --- compiler.go | 29 +++++++++++++++++++++++++++++ compiler_expr.go | 24 ++++++++++++++++++++++++ compiler_stmt.go | 4 ++++ vm.go | 3 +++ 4 files changed, 60 insertions(+) diff --git a/compiler.go b/compiler.go index 503a27ae8..d0892b2d1 100644 --- a/compiler.go +++ b/compiler.go @@ -897,6 +897,35 @@ func (s *scope) makeNamesMap() map[unistring.String]uint32 { return names } +// makeDebugStashNamesMap builds a stash names map for debugger introspection +// in non-dynamic scopes. Unlike makeNamesMap (which uses binding index and is +// only correct when ALL bindings are in the stash), this uses actual stash +// indices matching the allocation in finaliseVarAlloc. +func (s *scope) makeDebugStashNamesMap() map[unistring.String]uint32 { + var names map[unistring.String]uint32 + stashIdx := uint32(0) + for _, b := range s.bindings { + if b.inStash { + if names == nil { + names = make(map[unistring.String]uint32) + } + idx := stashIdx + if b.isConst { + idx |= maskConst + if b.isStrict { + idx |= maskStrict + } + } + if b.isVar { + idx |= maskVar + } + names[b.name] = idx + stashIdx++ + } + } + return names +} + func (s *scope) isDynamic() bool { return s.dynLookup || s.dynamic } diff --git a/compiler_expr.go b/compiler_expr.go index 10a9e4a35..379d885df 100644 --- a/compiler_expr.go +++ b/compiler_expr.go @@ -1726,6 +1726,28 @@ func (e *compiledFunctionLiteral) compile() (prg *Program, name unistring.String } if s.isDynamic() { enter1.names = s.makeNamesMap() + } else if e.c.debugMode && stashSize > 0 { + enter1.names = s.makeDebugStashNamesMap() + } + if e.c.debugMode && stackSize > 0 { + localIdx := 0 + for i, b := range s.bindings { + if b.name == thisBindingName || b.inStash { + continue + } + if i < int(s.numArgs) { + if enter1.dbgNames == nil { + enter1.dbgNames = make(map[unistring.String]int) + } + enter1.dbgNames[b.name] = -(i + 1) + } else { + if enter1.dbgNames == nil { + enter1.dbgNames = make(map[unistring.String]int) + } + enter1.dbgNames[b.name] = localIdx + localIdx++ + } + } } enter = &enter1 if enterFunc2Mark != -1 { @@ -1746,6 +1768,8 @@ func (e *compiledFunctionLiteral) compile() (prg *Program, name unistring.String } if s.isDynamic() { enter1.names = s.makeNamesMap() + } else if e.c.debugMode && stashSize > 0 { + enter1.names = s.makeDebugStashNamesMap() } enter = &enter1 if enterFunc2Mark != -1 { diff --git a/compiler_stmt.go b/compiler_stmt.go index 7ed3dcc3b..64c498524 100644 --- a/compiler_stmt.go +++ b/compiler_stmt.go @@ -103,6 +103,10 @@ func (c *compiler) updateEnterBlock(enter *enterBlock) { } enter.stashSize, enter.stackSize = uint32(stashSize), uint32(stackSize) + if c.debugMode && stashSize > 0 && !scope.dynLookup { + enter.names = scope.makeDebugStashNamesMap() + } + // Build debug names map for stack-register variables so the debugger // can enumerate and eval let/const variables that aren't in stash. if c.debugMode && stackSize > 0 && !scope.dynLookup { diff --git a/vm.go b/vm.go index 3fc3252c1..c49d2ea74 100644 --- a/vm.go +++ b/vm.go @@ -3925,6 +3925,9 @@ func (e *enterFuncBody) exec(vm *vm) { } } vm.sp = nsp + if vm.dbg != nil && len(e.dbgNames) > 0 { + vm.dbgPushBlockScope(e.dbgNames, sp) + } vm.pc++ } From ed0328a0a462007cc528ae18974fa13915225001 Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Mon, 23 Feb 2026 18:42:36 +1000 Subject: [PATCH 4/5] fix --- debugger.go | 109 +++++++++++++++++++++++++++++++++++++------ debugger/handlers.go | 41 ++++++++-------- debugger_test.go | 63 +++++++++++++++++++++++++ vm_debug.go | 5 +- 4 files changed, 180 insertions(+), 38 deletions(-) diff --git a/debugger.go b/debugger.go index 641666794..0fd6915cc 100644 --- a/debugger.go +++ b/debugger.go @@ -5,6 +5,7 @@ import ( "path/filepath" "regexp" "runtime" + "sort" "strconv" "strings" "sync" @@ -180,10 +181,10 @@ type Debugger struct { // Breakpoint management — protected by bpMu bpMu sync.RWMutex breakpoints map[int]*Breakpoint // id -> Breakpoint - nextBpID int //nolint:unused + nextBpID int //nolint:unused bpIndex map[string]map[int][]*Breakpoint // canonical filename -> line -> breakpoints - bpByBase map[string]string // basename -> canonical path (for cross-resolution) - bpCount int32 // atomic; fast-path: skip map lookup when 0 + bpByBase map[string][]string // basename -> canonical paths (for cross-resolution) + bpCount int32 // atomic; fast-path: skip map lookup when 0 // Exception breakpoints — protected by bpMu exFilterAll bool // pause on all thrown exceptions @@ -216,7 +217,7 @@ func NewDebugger(hook DebugHookFunc) *Debugger { hook: hook, breakpoints: make(map[int]*Breakpoint), bpIndex: make(map[string]map[int][]*Breakpoint), - bpByBase: make(map[string]string), + bpByBase: make(map[string][]string), } } @@ -285,9 +286,19 @@ func (d *Debugger) SetBreakpoint(filename string, line, column int, opts ...Brea // Register basename → canonical mapping for cross-resolution. // This allows breakpoints set with full paths (e.g. from VS Code) to match // goja sources registered with short names (e.g. "fibonacci.ts"), and vice versa. + // Multiple files can share a basename (e.g. "includes/tests.ts" and "common/tests.ts"), + // so we store all canonical paths per basename. base := filepath.Base(canonical) - if _, exists := d.bpByBase[base]; !exists { - d.bpByBase[base] = canonical + candidates := d.bpByBase[base] + found := false + for _, c := range candidates { + if c == canonical { + found = true + break + } + } + if !found { + d.bpByBase[base] = append(candidates, canonical) } atomic.AddInt32(&d.bpCount, 1) @@ -382,9 +393,29 @@ func (d *Debugger) shouldPause(vm *vm) (DebugEvent, *Breakpoint, bool) { if !ok { // Fallback: try matching by basename (handles VS Code full paths // matching goja sources registered with short names, and vice versa). + // When multiple files share a basename, use the program's compile name + // as a suffix to disambiguate (e.g. "includes/tests.ts" vs "common/tests.ts"). base := filepath.Base(canonical) - if altPath, found := d.bpByBase[base]; found && altPath != canonical { - lineMap, ok = d.bpIndex[altPath] + if candidates, found := d.bpByBase[base]; found { + prgName := "" + if vm.prg.src != nil { + prgName = filepath.ToSlash(vm.prg.src.Name()) + } + for _, altPath := range candidates { + if altPath == canonical { + continue + } + if prgName != "" && pathHasSuffix(altPath, prgName) { + lineMap, ok = d.bpIndex[altPath] + break + } + } + // If no suffix match and only one candidate, use it only when we + // have no program name to disambiguate with (original behavior + // for inline/eval scripts). + if !ok && prgName == "" && len(candidates) == 1 && candidates[0] != canonical { + lineMap, ok = d.bpIndex[candidates[0]] + } } } if ok { @@ -533,14 +564,15 @@ func (vm *vm) debugScopes(frameIndex int) []DebugScope { var scopes []DebugScope // Enumerate stack-register variables from dbgScopes for this frame. + // Function-level scopes (isFunc=true) are collected separately and merged + // into the first stash-based "Local" scope below, so all function-local + // variables appear under one scope in the debugger. + var funcScopeVars []DebugVariable if start, end := vm.dbgScopeRange(frameIndex); end > start { // Iterate in reverse (innermost scope first) for i := end - 1; i >= start; i-- { ds := vm.dbgScopes[i] - scope := DebugScope{ - Type: "block", - Name: "Block", - } + var vars []DebugVariable for name, stackIdx := range ds.vars { var val Value if stackIdx >= 0 && stackIdx < len(vm.stack) { @@ -549,12 +581,21 @@ func (vm *vm) debugScopes(frameIndex int) []DebugScope { if val == nil { val = _undefined } - scope.Variables = append(scope.Variables, DebugVariable{ + vars = append(vars, DebugVariable{ Name: name.String(), Value: val, }) } - scopes = append(scopes, scope) + if ds.isFunc { + // Defer function-level variables to merge with the stash "Local" scope. + funcScopeVars = append(funcScopeVars, vars...) + } else { + scopes = append(scopes, DebugScope{ + Type: "block", + Name: "Block", + Variables: vars, + }) + } } } @@ -583,6 +624,9 @@ func (vm *vm) debugScopes(frameIndex int) []DebugScope { } else if isLocal { scope.Type = "local" scope.Name = "Local" + // Merge function-level stack-register variables into this scope. + scope.Variables = append(scope.Variables, funcScopeVars...) + funcScopeVars = nil isLocal = false } else { scope.Type = "closure" @@ -634,6 +678,24 @@ func (vm *vm) debugScopes(frameIndex int) []DebugScope { scopes = append(scopes, scope) s = s.outer } + + // If there was no stash-based "Local" scope to merge into (e.g., stashless + // function where all variables are on the stack), emit them as "Local". + if len(funcScopeVars) > 0 { + scopes = append(scopes, DebugScope{ + Type: "local", + Name: "Local", + Variables: funcScopeVars, + }) + } + + // Sort variables alphabetically within each scope for stable, readable output. + for i := range scopes { + sort.Slice(scopes[i].Variables, func(a, b int) bool { + return scopes[i].Variables[a].Name < scopes[i].Variables[b].Name + }) + } + return scopes } @@ -827,6 +889,25 @@ func (vm *vm) debugSetVariable(frameIndex, scopeIndex int, name string, value Va return fmt.Errorf("variable %q not found", name) } +// pathHasSuffix reports whether fullPath ends with suffix at a path separator boundary. +// Both paths are compared using forward slashes. For example: +// +// pathHasSuffix("/a/b/includes/tests.ts", "includes/tests.ts") => true +// pathHasSuffix("/a/b/includes/tests.ts", "common/tests.ts") => false +// pathHasSuffix("/a/b/includes/tests.ts", "tests.ts") => true +func pathHasSuffix(fullPath, suffix string) bool { + full := filepath.ToSlash(fullPath) + sfx := filepath.ToSlash(suffix) + if !strings.HasSuffix(full, sfx) { + return false + } + // Ensure match is at a path boundary (preceded by '/' or is the entire path). + if len(full) == len(sfx) { + return true + } + return full[len(full)-len(sfx)-1] == '/' +} + // canonicalizePath normalizes a filename for consistent breakpoint matching. func canonicalizePath(filename string) string { if filename == "" { diff --git a/debugger/handlers.go b/debugger/handlers.go index 055331d90..8a3d3f60c 100644 --- a/debugger/handlers.go +++ b/debugger/handlers.go @@ -5,7 +5,6 @@ import ( "fmt" "net/url" "path/filepath" - "reflect" "strings" "github.com/dop251/goja" @@ -464,13 +463,7 @@ func (s *Server) valueToVariable(name string, val goja.Value) dap.Variable { } v := dap.Variable{ - Name: name, - Value: val.String(), - } - - exportType := val.ExportType() - if exportType != nil { - v.Type = exportType.Kind().String() + Name: name, } switch { @@ -489,21 +482,25 @@ func (s *Server) valueToVariable(name string, val goja.Value) dap.Variable { default: if obj, ok := val.(*goja.Object); ok { v.VariablesReference = s.refs.AddObject(obj) - // Determine a more specific type - exported := obj.Export() - if exported != nil { - rt := reflect.TypeOf(exported) - switch rt.Kind() { - case reflect.Func: - v.Type = "function" - v.Value = "function" - case reflect.Slice, reflect.Array: - v.Type = "array" - default: - v.Type = "object" - } - } else { + // Use ClassName() which is safe (no JS execution) instead of + // String() which calls toString() and can corrupt VM state. + cls := obj.ClassName() + switch cls { + case "Function": + v.Type = "function" + v.Value = "function" + case "Array": + v.Type = "array" + v.Value = cls + default: v.Type = "object" + v.Value = cls + } + } else { + // Primitives: String() is safe (no JS execution for int, float, bool, string). + v.Value = val.String() + if exportType := val.ExportType(); exportType != nil { + v.Type = exportType.Kind().String() } } } diff --git a/debugger_test.go b/debugger_test.go index e739ffe70..75c9d4066 100644 --- a/debugger_test.go +++ b/debugger_test.go @@ -804,6 +804,69 @@ func TestSetVariable(t *testing.T) { } } +func TestPathHasSuffix(t *testing.T) { + tests := []struct { + full, suffix string + want bool + }{ + {"/a/b/includes/tests.ts", "includes/tests.ts", true}, + {"/a/b/includes/tests.ts", "common/tests.ts", false}, + {"/a/b/includes/tests.ts", "tests.ts", true}, + {"/a/b/includes/tests.ts", "b/includes/tests.ts", true}, + {"/a/b/includes/tests.ts", "cludes/tests.ts", false}, // not at path boundary + {"includes/tests.ts", "includes/tests.ts", true}, + {"/a/b/c", "c", true}, + {"/a/b/c", "b/c", true}, + {"/a/b/c", "a/b/c", true}, + {"/a/b/c", "/a/b/c", true}, + } + for _, tt := range tests { + got := pathHasSuffix(tt.full, tt.suffix) + if got != tt.want { + t.Errorf("pathHasSuffix(%q, %q) = %v, want %v", tt.full, tt.suffix, got, tt.want) + } + } +} + +// TestBreakpointMultiFileDisambiguation verifies that when two programs share +// the same basename (e.g. "includes/tests.ts" and "common/tests.ts"), a +// breakpoint set on one file does NOT fire in the other. +func TestBreakpointMultiFileDisambiguation(t *testing.T) { + r := New() + var hitPositions []DebugPosition + dbg := NewDebugger(func(ctx *DebugContext, event DebugEvent, pos DebugPosition) DebugAction { + if event == DebugEventBreakpoint { + hitPositions = append(hitPositions, pos) + } + return DebugContinue + }) + + // Simulate IDE setting a breakpoint at an absolute path for "includes/tests.ts" line 2. + // This path does NOT match the program compiled as "common/tests.ts". + dbg.SetBreakpoint("/workspace/templates/includes/tests.ts", 2, 0) + r.SetDebugger(dbg) + + // Run a program compiled as "common/tests.ts" — should NOT trigger the breakpoint. + prg1, _ := r.Compile("common/tests.ts", "var a = 1;\nvar b = 2;\nvar c = 3;", false) + r.RunProgram(prg1) + + if len(hitPositions) != 0 { + t.Fatalf("Expected 0 breakpoint hits for common/tests.ts, got %d at %v", + len(hitPositions), hitPositions) + } + + // Run a program compiled as "includes/tests.ts" — SHOULD trigger the breakpoint. + prg2, _ := r.Compile("includes/tests.ts", "var x = 1;\nvar y = 2;\nvar z = 3;", false) + r.RunProgram(prg2) + + if len(hitPositions) != 1 { + t.Fatalf("Expected 1 breakpoint hit for includes/tests.ts, got %d", len(hitPositions)) + } + if hitPositions[0].Line != 2 { + t.Fatalf("Expected breakpoint at line 2, got line %d", hitPositions[0].Line) + } +} + func TestEvalHitCondition(t *testing.T) { tests := []struct { expr string diff --git a/vm_debug.go b/vm_debug.go index 9a6a15776..85a8d14fd 100644 --- a/vm_debug.go +++ b/vm_debug.go @@ -9,7 +9,8 @@ import ( // dbgScopeInfo tracks an active scope's stack-register variables for the debugger. // Each variable maps to an absolute index in vm.stack, computed at scope entry time. type dbgScopeInfo struct { - vars map[unistring.String]int // variable name → absolute stack index + vars map[unistring.String]int // variable name → absolute stack index + isFunc bool // true for function-level scopes (args + locals) } // debuggerInstr implements the JS `debugger` statement. @@ -211,7 +212,7 @@ func (vm *vm) dbgPushFuncScope(dbgNames map[unistring.String]int, sb, args int) vars[name] = sb + args + 1 + offset } } - vm.dbgScopes = append(vm.dbgScopes, dbgScopeInfo{vars: vars}) + vm.dbgScopes = append(vm.dbgScopes, dbgScopeInfo{vars: vars, isFunc: true}) } // dbgPopScope pops the topmost debug scope entry. From d94758d3fee7312791ee6527c7d4340cd441e7fe Mon Sep 17 00:00:00 2001 From: Tristan Cartledge Date: Wed, 25 Feb 2026 16:10:25 +1000 Subject: [PATCH 5/5] refactor: clean up debugger code for PR readiness --- compiler.go | 25 ++++++++++ compiler_expr.go | 38 +-------------- debugger.go | 7 +-- debugger/README.md | 105 ++++++++++++++++++++++-------------------- debugger/server.go | 112 +++++++++++---------------------------------- 5 files changed, 112 insertions(+), 175 deletions(-) diff --git a/compiler.go b/compiler.go index d0892b2d1..5a94835d7 100644 --- a/compiler.go +++ b/compiler.go @@ -926,6 +926,31 @@ func (s *scope) makeDebugStashNamesMap() map[unistring.String]uint32 { return names } +// makeDebugRegisterNamesMap builds a debug names map for stack-register (non-stash) +// variables so the debugger can see them. Arguments are encoded with negative +// indices (-(argIndex+1)), locals with sequential non-negative indices. +// When skipInStash is true, bindings that live in the stash are skipped (used +// for functions that have both stash and register variables). +func (s *scope) makeDebugRegisterNamesMap(skipInStash bool) map[unistring.String]int { + names := make(map[unistring.String]int, len(s.bindings)) + localIdx := 0 + for i, b := range s.bindings { + if b.name == thisBindingName || (skipInStash && b.inStash) { + continue + } + if i < int(s.numArgs) { + names[b.name] = -(i + 1) + } else { + names[b.name] = localIdx + localIdx++ + } + } + if len(names) == 0 { + return nil + } + return names +} + func (s *scope) isDynamic() bool { return s.dynLookup || s.dynamic } diff --git a/compiler_expr.go b/compiler_expr.go index 379d885df..4ffc884b7 100644 --- a/compiler_expr.go +++ b/compiler_expr.go @@ -1730,24 +1730,7 @@ func (e *compiledFunctionLiteral) compile() (prg *Program, name unistring.String enter1.names = s.makeDebugStashNamesMap() } if e.c.debugMode && stackSize > 0 { - localIdx := 0 - for i, b := range s.bindings { - if b.name == thisBindingName || b.inStash { - continue - } - if i < int(s.numArgs) { - if enter1.dbgNames == nil { - enter1.dbgNames = make(map[unistring.String]int) - } - enter1.dbgNames[b.name] = -(i + 1) - } else { - if enter1.dbgNames == nil { - enter1.dbgNames = make(map[unistring.String]int) - } - enter1.dbgNames[b.name] = localIdx - localIdx++ - } - } + enter1.dbgNames = s.makeDebugRegisterNamesMap(true) } enter = &enter1 if enterFunc2Mark != -1 { @@ -1792,24 +1775,7 @@ func (e *compiledFunctionLiteral) compile() (prg *Program, name unistring.String } // Populate dbgNames so the debugger can see stack-register variables. if e.c.debugMode && (stackSize > 0 || paramsCount > 0) { - localIdx := 0 - for i, b := range s.bindings { - if b.name == thisBindingName { - continue - } - if i < int(s.numArgs) { - if efl.dbgNames == nil { - efl.dbgNames = make(map[unistring.String]int) - } - efl.dbgNames[b.name] = -(i + 1) - } else { - if efl.dbgNames == nil { - efl.dbgNames = make(map[unistring.String]int) - } - efl.dbgNames[b.name] = localIdx - localIdx++ - } - } + efl.dbgNames = s.makeDebugRegisterNamesMap(false) } enter = efl if enterFunc2Mark != -1 { diff --git a/debugger.go b/debugger.go index 0fd6915cc..a1e0b521c 100644 --- a/debugger.go +++ b/debugger.go @@ -180,8 +180,8 @@ type Debugger struct { // Breakpoint management — protected by bpMu bpMu sync.RWMutex - breakpoints map[int]*Breakpoint // id -> Breakpoint - nextBpID int //nolint:unused + breakpoints map[int]*Breakpoint // id -> Breakpoint + nextBpID int bpIndex map[string]map[int][]*Breakpoint // canonical filename -> line -> breakpoints bpByBase map[string][]string // basename -> canonical paths (for cross-resolution) bpCount int32 // atomic; fast-path: skip map lookup when 0 @@ -424,7 +424,8 @@ func (d *Debugger) shouldPause(vm *vm) (DebugEvent, *Breakpoint, bool) { if bp.Column == 0 || bp.Column == pos.Column { d.bpMu.RUnlock() - // Increment hit count + // Increment hit count — safe without bpMu because shouldPause + // is only called from the VM goroutine, which is the sole writer. bp.hitCount++ // Check hit condition diff --git a/debugger/README.md b/debugger/README.md index 8d60f74be..c62ca4192 100644 --- a/debugger/README.md +++ b/debugger/README.md @@ -21,14 +21,19 @@ A [Debug Adapter Protocol](https://microsoft.github.io/debug-adapter-protocol/) ### 1. Install the VS Code extension -The extension is at `debugger/vscode-goja-debugger/`. Install it in VS Code: +The extension is at `debugger/vscode-goja-debugger/`. A pre-built `.vsix` is +included in the repository for convenience. Install it in VS Code: ```bash cd debugger/vscode-goja-debugger -# Option A: symlink into VS Code extensions directory + +# Option A (quickest): install the pre-built VSIX +code --install-extension vscode-goja-debugger-0.0.3.vsix + +# Option B: symlink into VS Code extensions directory ln -s "$(pwd)" ~/.vscode-server/extensions/vscode-goja-debugger -# Option B: package and install as VSIX +# Option C: package a fresh VSIX and install npx @vscode/vsce package code --install-extension vscode-goja-debugger-*.vsix ``` @@ -127,12 +132,12 @@ Set breakpoints in your `.js` or `.ts` files and press **F5**. Goja supports two compilation modes. The debugger needs debug metadata (`dbgNames` maps) to display `let`/`const` variables and function parameters. Without it, only `var` declarations and closure variables are visible. -| Function | Debug info | Use case | -| -------- | ---------- | -------- | -| `goja.Compile()` | No | Production — zero overhead | -| `goja.CompileForDebug()` | Yes | Pre-compiled programs for debugging | -| `r.Compile()` | Auto | Detects debugger at compile time | -| `r.RunString()` / `r.RunScript()` | Auto | Auto-detects if debugger is attached | +| Function | Debug info | Use case | +| --------------------------------- | ---------- | ------------------------------------ | +| `goja.Compile()` | No | Production — zero overhead | +| `goja.CompileForDebug()` | Yes | Pre-compiled programs for debugging | +| `r.Compile()` | Auto | Detects debugger at compile time | +| `r.RunString()` / `r.RunScript()` | Auto | Auto-detects if debugger is attached | **Recommendation:** Use `r.Compile()` or `r.RunString()` — they automatically enable debug info when a debugger is attached, and skip it when not. @@ -159,14 +164,14 @@ p, err := r.Compile("script.js", src, false) } ``` -| Field | Description | -| ----- | ----------- | -| `program` | Path to the Go package directory (must contain `main.go`) | -| `args` | Arguments passed to the Go program after `--port` | -| `port` | TCP port for the DAP server (default: `4711`) | -| `buildArgs` | Extra flags for `go run` (e.g., `-race`, `-tags`) | -| `cwd` | Working directory (defaults to `program` directory) | -| `env` | Extra environment variables | +| Field | Description | +| ----------- | --------------------------------------------------------- | +| `program` | Path to the Go package directory (must contain `main.go`) | +| `args` | Arguments passed to the Go program after `--port` | +| `port` | TCP port for the DAP server (default: `4711`) | +| `buildArgs` | Extra flags for `go run` (e.g., `-race`, `-tags`) | +| `cwd` | Working directory (defaults to `program` directory) | +| `env` | Extra environment variables | The extension runs: `go run [buildArgs...] . --port PORT [args...]` @@ -321,24 +326,24 @@ r.SetDebugger(nil) // detach when done The hook function returns a `DebugAction` that tells the VM what to do next: -| Action | Behavior | -| ------ | -------- | -| `DebugActionContinue` | Resume execution until next breakpoint | +| Action | Behavior | +| --------------------- | --------------------------------------- | +| `DebugActionContinue` | Resume execution until next breakpoint | | `DebugActionStepOver` | Execute current line, stop at next line | -| `DebugActionStepInto` | Step into function calls | -| `DebugActionStepOut` | Run until the current function returns | +| `DebugActionStepInto` | Step into function calls | +| `DebugActionStepOut` | Run until the current function returns | ### Debug events The hook receives a `DebugEvent` indicating why execution paused: -| Event | Trigger | -| ----- | ------- | -| `DebugEventBreakpoint` | Hit a breakpoint | -| `DebugEventStep` | Step operation completed | -| `DebugEventDebuggerStatement` | `debugger` statement in JS | -| `DebugEventPause` | `RequestPause()` was called | -| `DebugEventException` | Exception thrown (when exception breakpoints are active) | +| Event | Trigger | +| ----------------------------- | -------------------------------------------------------- | +| `DebugEventBreakpoint` | Hit a breakpoint | +| `DebugEventStep` | Step operation completed | +| `DebugEventDebuggerStatement` | `debugger` statement in JS | +| `DebugEventPause` | `RequestPause()` was called | +| `DebugEventException` | Exception thrown (when exception breakpoints are active) | ## Architecture @@ -367,27 +372,27 @@ When the VM pauses, the debug hook blocks the VM goroutine. The server goroutine ## Supported DAP Requests -| Request | Status | -| ------- | ------ | -| initialize | Supported | -| launch | Supported | -| attach | Supported | -| setBreakpoints | Supported (line, conditional, hit count, log point) | -| setExceptionBreakpoints | Supported (`all`, `uncaught` filters) | -| configurationDone | Supported | -| threads | Supported (single thread) | -| stackTrace | Supported | -| scopes | Supported | -| variables | Supported (with object expansion) | -| setVariable | Supported | -| evaluate | Supported (in any frame context) | -| continue | Supported | -| next (step over) | Supported | -| stepIn | Supported | -| stepOut | Supported | -| pause | Supported | -| terminate | Supported | -| disconnect | Supported | +| Request | Status | +| ----------------------- | --------------------------------------------------- | +| initialize | Supported | +| launch | Supported | +| attach | Supported | +| setBreakpoints | Supported (line, conditional, hit count, log point) | +| setExceptionBreakpoints | Supported (`all`, `uncaught` filters) | +| configurationDone | Supported | +| threads | Supported (single thread) | +| stackTrace | Supported | +| scopes | Supported | +| variables | Supported (with object expansion) | +| setVariable | Supported | +| evaluate | Supported (in any frame context) | +| continue | Supported | +| next (step over) | Supported | +| stepIn | Supported | +| stepOut | Supported | +| pause | Supported | +| terminate | Supported | +| disconnect | Supported | ## Thread Safety diff --git a/debugger/server.go b/debugger/server.go index 8d8473fbe..d5e3035a4 100644 --- a/debugger/server.go +++ b/debugger/server.go @@ -2,7 +2,6 @@ package debugger import ( "bufio" - "encoding/json" "fmt" "io" "net" @@ -16,11 +15,11 @@ import ( // Server is a DAP (Debug Adapter Protocol) server for a goja Runtime. // It bridges DAP messages from an IDE (e.g., VS Code) to goja's debug API. type Server struct { - runtime *goja.Runtime + runtime *goja.Runtime debugger *goja.Debugger - reader *bufio.Reader - writer io.Writer - runFunc func() error + reader *bufio.Reader + writer io.Writer + runFunc func() error // Outgoing message sequencing sendMu sync.Mutex @@ -30,11 +29,11 @@ type Server struct { refs *RefManager // Hook bridge channels - stoppedCh chan stopInfo // VM → Server: VM paused - inspectCh chan inspectRequest // Server → VM: inspect while paused - resumeCh chan goja.DebugAction // Server → VM: resume - logCh chan logEntry // VM → Server: log point output - disconnectCh chan struct{} // closed on disconnect to unblock debugHook + stoppedCh chan stopInfo // VM → Server: VM paused + inspectCh chan inspectRequest // Server → VM: inspect while paused + resumeCh chan goja.DebugAction // Server → VM: resume + logCh chan logEntry // VM → Server: log point output + disconnectCh chan struct{} // closed on disconnect to unblock debugHook // Session state configured bool @@ -84,15 +83,15 @@ type dapMessage struct { // It runs on a new goroutine and should execute JS code via the runtime. func NewServer(r *goja.Runtime, reader io.Reader, writer io.Writer, runFunc func() error) *Server { return &Server{ - runtime: r, - reader: bufio.NewReader(reader), - writer: writer, - runFunc: runFunc, - refs: NewRefManager(), - stoppedCh: make(chan stopInfo, 1), - inspectCh: make(chan inspectRequest), - resumeCh: make(chan goja.DebugAction), - logCh: make(chan logEntry, 16), + runtime: r, + reader: bufio.NewReader(reader), + writer: writer, + runFunc: runFunc, + refs: NewRefManager(), + stoppedCh: make(chan stopInfo, 1), + inspectCh: make(chan inspectRequest), + resumeCh: make(chan goja.DebugAction), + logCh: make(chan logEntry, 16), doneCh: make(chan error, 1), disconnectCh: make(chan struct{}), sourcePathMap: make(map[string]string), @@ -275,70 +274,14 @@ func eventToReason(event goja.DebugEvent) string { } // setSeq sets the Seq field on any DAP message. +// All go-dap Response/Event types implement ResponseMessage/EventMessage, +// giving access to the embedded ProtocolMessage.Seq field. func setSeq(msg dap.Message, seq int) { - // go-dap messages embed ProtocolMessage which has Seq. - // We use JSON round-trip to set it generically. This could be done - // more efficiently with a type switch, but send is not hot path. - type hasSeq interface { - GetSeq() int - } - // Use reflection-free approach: marshal, patch, unmarshal is overkill. - // Instead, use the concrete type knowledge: switch m := msg.(type) { - case *dap.InitializeResponse: - m.Seq = seq - case *dap.LaunchResponse: - m.Seq = seq - case *dap.AttachResponse: - m.Seq = seq - case *dap.SetBreakpointsResponse: - m.Seq = seq - case *dap.SetExceptionBreakpointsResponse: - m.Seq = seq - case *dap.ConfigurationDoneResponse: - m.Seq = seq - case *dap.ContinueResponse: - m.Seq = seq - case *dap.NextResponse: - m.Seq = seq - case *dap.StepInResponse: - m.Seq = seq - case *dap.StepOutResponse: - m.Seq = seq - case *dap.PauseResponse: - m.Seq = seq - case *dap.StackTraceResponse: - m.Seq = seq - case *dap.ScopesResponse: - m.Seq = seq - case *dap.VariablesResponse: - m.Seq = seq - case *dap.EvaluateResponse: - m.Seq = seq - case *dap.SetVariableResponse: - m.Seq = seq - case *dap.ThreadsResponse: - m.Seq = seq - case *dap.DisconnectResponse: - m.Seq = seq - case *dap.TerminateResponse: - m.Seq = seq - case *dap.ErrorResponse: - m.Seq = seq - case *dap.InitializedEvent: - m.Seq = seq - case *dap.StoppedEvent: - m.Seq = seq - case *dap.ContinuedEvent: - m.Seq = seq - case *dap.TerminatedEvent: - m.Seq = seq - case *dap.ThreadEvent: - m.Seq = seq - case *dap.OutputEvent: - m.Seq = seq - case *dap.ExitedEvent: - m.Seq = seq + case dap.ResponseMessage: + m.GetResponse().Seq = seq + case dap.EventMessage: + m.GetEvent().Seq = seq } } @@ -376,8 +319,8 @@ func ServeTCP(r *goja.Runtime, addr string, runFunc func() error) error { type TCPSession struct { // Addr is the address the server is listening on. Useful when the // port was auto-assigned (addr ":0"). - Addr net.Addr - ln net.Listener + Addr net.Addr + ln net.Listener errCh chan error } @@ -521,6 +464,3 @@ func AttachTCP(r *goja.Runtime, addr string) (*AttachSession, error) { return as, nil } - -// Ensure json import is used (for launch args parsing in handlers). -var _ = json.Unmarshal