Skip to content

Add RuntimeHook API for instrumentation#697

Open
Asafrose wants to merge 1 commit intodop251:masterfrom
Asafrose:runtime-hook-api
Open

Add RuntimeHook API for instrumentation#697
Asafrose wants to merge 1 commit intodop251:masterfrom
Asafrose:runtime-hook-api

Conversation

@Asafrose
Copy link

@Asafrose Asafrose commented Feb 3, 2026

Summary

This PR adds a minimal, low-overhead hook interface that enables building debuggers, profilers, tracers, and coverage tools on top of goja.

Motivation

Currently, there's no way to instrument goja's execution for debugging or profiling purposes. Users who want to build development tools (debuggers, step-through execution, breakpoints, profilers, coverage analyzers) have no supported mechanism to hook into the runtime.

This PR addresses that by providing a clean, minimal API that:

  • Has zero overhead when no hook is attached (single nil check in hot path)
  • Provides hooks at key execution points
  • Enables pause/resume for interactive debugging
  • Exposes runtime state needed for debugging (scopes, call stack, VM state)

API Overview

RuntimeHook Interface

type RuntimeHook interface {
    // Called before each VM instruction - enables breakpoints, stepping
    OnInstruction(rt *Runtime, pc int) HookResult
    
    // Called when entering/exiting JS functions
    OnFunctionEnter(rt *Runtime, name string, args []Value)
    OnFunctionExit(rt *Runtime, name string, result Value)
    
    // Called when an exception is thrown
    OnException(rt *Runtime, exception *Exception, caught bool) HookResult
    
    // Called when a promise reaction is enqueued
    OnPromiseReaction(rt *Runtime, promise *Object)
    
    // Called when a variable is assigned
    OnVariableSet(rt *Runtime, name string, value Value, scope ScopeType)
}

BaseRuntimeHook

For convenience, users can embed BaseRuntimeHook and only override the hooks they need:

type MyDebugger struct {
    goja.BaseRuntimeHook
}

func (d *MyDebugger) OnInstruction(rt *goja.Runtime, pc int) goja.HookResult {
    // Set breakpoints, implement stepping, etc.
    return goja.HookResultContinue
}

HookResult

Hooks that control execution flow return HookResult:

  • HookResultContinue - continue execution normally
  • HookResultPause - pause execution until Runtime.Resume() is called

Runtime Methods

  • SetRuntimeHook(hook RuntimeHook) - attach a hook
  • GetRuntimeHook() RuntimeHook - get current hook
  • Resume() error - resume paused execution
  • Scopes() []Scope - get current scope chain with variables
  • VMState() VMState - get low-level VM state (PC, SP, call depth, etc.)
  • LoadedScripts() []LoadedScript - list loaded scripts
  • FindPCsForLine(filename string, line int) []int - find PCs for breakpoints

StackFrame Methods

  • PC() int - get program counter
  • SourceCode() string - get source code for the frame

Design Decisions

  1. Single interface vs separate callbacks: Chose a single interface to match goja's existing patterns (e.g., FieldNameMapper) and provide better type safety.

  2. Pause mechanism: Uses sync.Cond rather than channels to avoid allocation overhead and enable efficient wait/signal semantics.

  3. Hook placement: OnInstruction is in the main VM loop but guarded by a nil check, ensuring zero overhead when not debugging.

  4. No eval/setVariable: Intentionally omitted runtime eval and variable mutation to keep the API minimal and avoid potential security concerns. Debuggers can be built externally using the provided introspection APIs.

Performance

When no hook is attached, the only overhead is a single nil pointer check per instruction:

if h := vm.r.runtimeHook; h != nil {
    // hook logic
}

This compiles to a single comparison and conditional jump, with no allocation or function call overhead.

Testing

Comprehensive tests are included covering:

  • All hook callbacks
  • Pause/resume functionality
  • Scope inspection
  • VM state inspection
  • Edge cases (recursion, exceptions, async, arrow functions)

Example Usage

type Debugger struct {
    goja.BaseRuntimeHook
    breakpoints map[string]map[int]bool // file -> line -> enabled
}

func (d *Debugger) OnInstruction(rt *goja.Runtime, pc int) goja.HookResult {
    frames := rt.CaptureCallStack(1, nil)
    if len(frames) > 0 {
        pos := frames[0].Position()
        if d.breakpoints[pos.Filename][pos.Line] {
            return goja.HookResultPause // Hit breakpoint
        }
    }
    return goja.HookResultContinue
}

Checklist

  • Follows existing goja code style and patterns
  • No significant performance impact when not in use
  • Comprehensive test coverage
  • All existing tests pass

Add a minimal, low-overhead hook interface that enables building debuggers,
profilers, tracers, and coverage tools on top of goja.

The API consists of:
- RuntimeHook interface with hooks for key execution points
- BaseRuntimeHook for convenient embedding (implement only needed hooks)
- HookResult enum for controlling execution (continue/pause)
- Runtime methods: SetRuntimeHook, GetRuntimeHook, Resume, Scopes, VMState,
  LoadedScripts, FindPCsForLine
- StackFrame methods: PC, SourceCode

Hooks provided:
- OnInstruction: called before each VM instruction (enables breakpoints, stepping)
- OnFunctionEnter: called when entering a JS function
- OnFunctionExit: called when exiting a JS function
- OnException: called when an exception is thrown
- OnPromiseReaction: called when a promise reaction is enqueued
- OnVariableSet: called when a variable is assigned

Design principles:
- Zero overhead when no hook attached (single nil check in hot path)
- Minimal API surface to reduce maintenance burden
- Uses sync.Cond for pause/resume (no polling)
- All hooks receive *Runtime to access full state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant