Skip to content

Add Debugging Interface#1421

Open
G-Yong wants to merge 1 commit into
quickjs-ng:masterfrom
G-Yong:master
Open

Add Debugging Interface#1421
G-Yong wants to merge 1 commit into
quickjs-ng:masterfrom
G-Yong:master

Conversation

@G-Yong
Copy link
Copy Markdown

@G-Yong G-Yong commented Mar 26, 2026

Add a debugging interface to the existing architecture.
Using the added debugging interface, we implemented debugging for QuickJS in VSCode. The project address is:[QuickJS-Debugger]
debugInVSCode

@saghul
Copy link
Copy Markdown
Contributor

saghul commented Mar 26, 2026

At a quick glance, this looks better than the other approaches I've seen, kudos!

Now, since this will have a performance impact, I'd say we want it gated with a compile time time macro.

@bnoordhuis thoughts?

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Mar 28, 2026

Thanks for the feedback @saghul! I've added a compile-time macro QJS_ENABLE_DEBUGGER to gate all the debug-related code. Here's a summary of the changes:

Compile-time gating (QJS_ENABLE_DEBUGGER)

  • All debug fields in JSContext, all debug API implementations (JS_SetOPChangedHandler, JS_GetStackDepth , JS_GetLocalVariablesAtLevel, JS_FreeLocalVariables ) , and the per-opcode callback in JS_CallInternal are now wrapped in #ifdef QJS_ENABLE_DEBUGGER.
  • The declarations in quickjs.h are similarly guarded.
  • When QJS_ENABLE_DEBUGGER is defined, DIRECT_DISPATCH is automatically set to 0 (alongside the existing EMSCRIPTEN and _MSC_VER conditions), so the switch-based dispatch is used and the opcode callback fires correctly.
  • A new xoption(QJS_ENABLE_DEBUGGER ...) has been added to CMakeLists.txt , defaulting to OFF. When not enabled, there is zero overhead — no extra struct fields, no callback checks in the hot path, and DIRECT_DISPATCH remains unaffected.

Other cleanups

  • Renamed JSLocalVar to JSDebugLocalVar to avoid confusion with the internal JSVarDef.
  • Fixed a potential memory leak where funcname was only freed inside if (filename).
  • Removed leftover debug fprintf comments and unnecessary braces in the hot path.

Let me know if there's anything else you'd like adjusted.

@saghul
Copy link
Copy Markdown
Contributor

saghul commented Mar 28, 2026

@bnoordhuis WDYT?

Copy link
Copy Markdown
Contributor

@bnoordhuis bnoordhuis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Behind a compile-time flag would be good. The diff mostly looks good to me but there are a couple of changes I'd like to see:

  • the API functions should always be available but return an error when compiled without debugger support

  • can you rename the new emit_source_loc calls to something like emit_source_loc_debug and turn that into a no-op when there's no debugger support?

  • I don't love the name JSOPChangedHandler. Suggestions for a better one? Something like JSBytecodeTraceFunc perhaps?

I'm assuming this good enough as a building block to assemble a functional debugger from? I can see how it lets you single-step through code, inspect stack frames, set breakpoints, etc., so I'm guessing... yes?

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Mar 30, 2026

Thanks for the review @bnoordhuis! I've addressed all your feedback:

1. API functions always available

Type definitions JSBytecodeTraceFunc, JSDebugLocalVar and all function declarations are now outside #ifdef QJS_ENABLE_DEBUGGER, so they're always visible. When compiled without debugger support, stub implementations are provided:

JS_SetBytecodeTraceHandler() — no-op
JS_GetStackDepth() — returns -1
JS_GetLocalVariablesAtLevel()— sets *pcount = 0, returns NULL
JS_FreeLocalVariables() — no-op
This way user code can link against these APIs unconditionally without #ifdef.

2. emit_source_loc_debug

Added a macro after emit_source_loc():

All 10 new call sites (for return, let/const, var, if, for, break/continue, switch, try/finally) now use emit_source_loc_debug(s). The 4 pre-existing emit_source_loc(s)calls (new-expression, function call, binary expression, throw) are left unchanged.

3. Renamed JSOPChangedHandler → JSBytecodeTraceFunc

Agreed, much better name. Full rename:

Old New
JSOPChangedHandler JSBytecodeTraceFunc
JS_SetOPChangedHandler JS_SetBytecodeTraceHandler
ctx->operation_changed ctx->bytecode_trace
ctx->oc_opaque ctx->trace_opaque

4. Re: building block for a functional debugger

Yes — the current API covers the essential building blocks: single-step, breakpoints, call stack inspection, and local variable read access. I've built a working VSCode debugger on top of it QuickJS-Debugger

For a more complete debugger experience, a few additional APIs could be added later (as separate PRs):

  • JS_EvalInStackFrame() — eval an expression in a specific frame's scope
  • Closure variable access (captured vars from outer scopes)
  • Exception hook (pause on throw)

But these can be built incrementally. The current PR is a solid minimal foundation.

@G-Yong G-Yong requested a review from bnoordhuis March 31, 2026 09:47
@bnoordhuis
Copy link
Copy Markdown
Contributor

I'm reasonably confident we can support this at runtime, no compile-time flags, without significant slowdowns when the debugger is inactive:

  1. add a new OP_debug opcode
  2. spray the generated bytecode with OP_debugs (but only when debugging is enabled)
  3. when the interpreter hits an OP_debug opcode, invoke a debugger callback

Proof of concept, very WIP: bnoordhuis/quickjs@f5fbb5b

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Apr 1, 2026

Thanks @bnoordhuis for the OP_debug proposal and the POC (f5fbb5b)! I've fully adopted this architecture.

Here's a summary of what's been done, and a few design choices I'd like to explain:

What we adopted from your POC

  • OP_debug opcode: DEF(debug, 1, 0, 0, none) right after OP_invalid — a real opcode that survives all optimization phases.
  • Runtime gating, no compile-time flags: Removed QJS_ENABLE_DEBUGGER entirely. DIRECT_DISPATCH is no longer forced to 0 by the debugger.
  • JS_NewDebugContext(rt, cb): Creates a debug-enabled context. s->emit_debug = (ctx->debug_break != NULL) controls OP_debug emission during parsing.
  • emit_source_loc emits OP_debug: When s->emit_debug is true, emit_source_loc() automatically appends OP_debug after OP_source_loc. Every source location point becomes a debug breakpoint.
  • code_match skips OP_debug: Pass 4 peephole optimizer transparently skips OP_debug during pattern matching, same as your POC.
  • emit_return instrumented: Added emit_source_loc(s) before OP_return_async and OP_return/OP_return_undef, matching your POC.
  • JS_GetContextOpaque for user data: No separate opaque parameter — the callback retrieves user data via JS_GetContextOpaque(ctx), exactly as in your POC.

Additional work beyond the POC

Since the POC was intentionally minimal ("very WIP"), I filled in the remaining pieces:

  • Phase 3 optimizer: Added explicit case OP_debug: with add_pc2line_info to preserve line info, then emit OP_debug to the output buffer.
  • code_has_label / find_jump_target: Updated to skip OP_debug (same pattern as OP_source_loc and OP_label).
  • Broad emit_source_loc coverage: 16 call sites covering return, let/const, var, if, for, break/continue, switch, try/finally, throw, expression statements, new expressions, function calls, binary expressions, and emit_return.
  • Debug APIs: JS_GetStackDepth(), JS_GetLocalVariablesAtLevel(), JS_FreeLocalVariables() — always available (no #ifdef guards).

Where we diverged (and why)

Callback signature

Your POC passes raw call-frame values:

typedef void JSDebugBreakFunc(JSContext *ctx, JSValueConst func_obj,
                              JSValueConst this_obj, JSValueConst new_target,
                              int argc, JSValueConst *argv);

We use resolved debug info instead:

typedef int JSDebugBreakFunc(JSContext *ctx,
                             const char *filename, const char *funcname,
                             int line, int col);

Reason: A debugger needs filename/line/col at every break point. With the raw-values signature, the callback would need public APIs to extract the current PC's line number from the bytecode — but find_line_num, pc2line, and the PC itself are all internal. Rather than exposing interpreter internals, we resolve the info inside CASE(OP_debug) where everything is already accessible and pass it directly. This keeps the public API surface minimal.

int return type

Our callback returns int (0 = continue, non-zero = raise exception). This gives the debugger a clean way to abort execution (e.g., user clicks "Stop" in VS Code) without requiring the callback to throw via JS_Throw + signal mechanism.


Happy to adjust any of this based on your feedback. The working VSCode debugger built on top of this is at QuickJS-Debugger (branch OP_debug).

Copy link
Copy Markdown
Contributor

@saghul saghul left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments! I like where this is going!

Comment thread quickjs.h Outdated
Comment thread quickjs.c Outdated
Comment thread quickjs.c Outdated
Comment thread quickjs.c Outdated
Comment thread quickjs.c Outdated
Comment thread quickjs.c Outdated
Comment thread quickjs.h Outdated
Comment thread quickjs.h
Comment thread quickjs.h
@saghul
Copy link
Copy Markdown
Contributor

saghul commented Apr 2, 2026

Since the bytecode needs to be regeneratred, can you bump the BC_VERSION please? You'll need to update the fuxxing tests too.

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Apr 2, 2026

Done — bumped BC_VERSION from 25 to 26 and updated the fuzz corpus in tests/test_bjson.js (version byte in the base64-encoded blobs).

@saghul
Copy link
Copy Markdown
Contributor

saghul commented Apr 2, 2026

Any chance you can add a small test in api-test.c ?

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Apr 2, 2026

Added a debug_trace test in api-test.c that covers:

  1. No handler — eval works, callback is never invoked
  2. Set handler — callback fires for each statement, receives correct filename
  3. Stack depthJS_GetStackDepth() returns ≥ 1 inside nested calls
  4. Local variablesJS_GetLocalVariablesAtLevel() / JS_FreeLocalVariables() see ≥ 2 locals inside f(a, b)
  5. Abort execution — returning non-zero from the callback raises an exception
  6. Clear handler — passing NULL to JS_SetDebugTraceHandler stops callbacks

@saghul
Copy link
Copy Markdown
Contributor

saghul commented Apr 2, 2026

Looks like you didn't regenerate the bundled bytecode after bumping it, and tests are failing due to that.

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Apr 3, 2026

Fixed — ran make codegen to regenerate all bundled bytecode files under gen/ (repl.c, standalone.c, hello.c, hello_module.c, test_fib.c, function_source.c) and the builtin-*.h headers with the updated BC_VERSION 26.

The reason I didn't include the codegen step earlier was intentional: regenerating these files produces a large diff across multiple generated files, which would have buried the actual debugging interface changes and made the PR much harder to review. Now that the core changes have been reviewed, I've added the regenerated files in a separate commit.

@saghul
Copy link
Copy Markdown
Contributor

saghul commented Apr 9, 2026

@bnoordhuis any chance you can take a look?

saghul
saghul previously approved these changes Apr 9, 2026
@bnoordhuis
Copy link
Copy Markdown
Contributor

I intentionally suggested emitting OP_debug conditionally because doing it unconditionally a) makes the bytecode bigger, and b) slows down the interpreter dispatch loop. Can you at least check that https://github.com/quickjs-ng/web-tooling-benchmark doesn't regress?

@saghul
Copy link
Copy Markdown
Contributor

saghul commented Apr 13, 2026

Ah sorry I think that's my bad, I misunderstood what you meant 😢

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Apr 14, 2026

I intentionally suggested emitting OP_debug conditionally because doing it unconditionally a) makes the bytecode bigger, and b) slows down the interpreter dispatch loop. Can you at least check that https://github.com/quickjs-ng/web-tooling-benchmark doesn't regress?

Okay, I will first conduct a comparative test based on the tools you provided to see the actual impact.

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Apr 14, 2026

Web Tooling Benchmark Performance Test Results

I ran web-tooling-benchmark (17 real-world JavaScript workloads) to measure the performance impact of this PR's OP_debug changes.

Test Environment

Item Detail
OS Windows 10.0.26200
Compiler MSVC 19.29.30159.0 (Visual Studio 2019)
Build CMake Release mode
Upstream quickjs-ng/quickjs master (v0.14.0)
Fork G-Yong/quickjs (this PR branch, with unconditional OP_debug)
Runs 3 per version, results averaged

Detailed Results (runs/s, higher is better)

Benchmark Upstream (avg) Fork (avg) Diff
acorn 0.59 0.57 -3.4%
babel 1.70 1.62 -4.7%
babel-minify 2.38 2.24 -5.9%
babylon 1.20 1.11 -7.5%
buble 1.99 1.95 -2.0%
chai 2.45 2.40 -2.0%
espree 0.44 0.42 -4.5%
esprima 0.81 0.78 -3.7%
jshint 1.95 1.90 -2.6%
lebab 1.52 1.54 +1.3%
postcss 1.55 1.47 -5.2%
prepack 2.08 1.93 -7.2%
prettier 0.81 0.78 -3.7%
source-map 1.04 1.02 -1.9%
terser 4.59 4.42 -3.7%
typescript 1.86 1.80 -3.2%
uglify-js 1.29 1.24 -3.9%
Geometric mean 1.43 1.38 -3.5%

Raw Run Data

Upstream: 1.43, 1.42, 1.45 (avg: 1.433)
Fork: 1.36, 1.39, 1.39 (avg: 1.380)

Summary

The unconditional OP_debug emission causes a ~3.5% overall performance regression (geometric mean). The impact is not uniform — parser-heavy benchmarks like babylon (-7.5%) and prepack (-7.2%) are hit harder, likely due to higher bytecode volume and more frequent OP_debug dispatch in tight loops.

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Apr 14, 2026

I'm a bit confused. @bnoordhuis, do you mean we should keep OP_debug but compile it conditionally? Also, regarding the emit_source_loc_debug issue mentioned earlier, there might be a problem. If we adopt emit_source_loc_debug, we not only need to add it at specific locations, but also replace the existing emit_source_loc with emit_source_loc_debug. Otherwise, the statements represented by the original emit_source_loc won't respond to OP_debug.

@saghul
Copy link
Copy Markdown
Contributor

saghul commented Apr 14, 2026

Sorry @G-Yong i added to the confusion.

As your benchmarks show, just by having the debug opcodes things get slower so it should be opt-in.

Not sure if compile time or config, @bnoordhuis what's on your mind?

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Apr 15, 2026

I didn't quite understand why emit_source_loc_debug was needed before. Now I get it a little bit— the newly added emit_source_loc calls are debug-specific (for stepping through if/for/return/etc.), unlike the original 5 upstream calls which are always needed for error reporting. Having emit_source_loc_debug as a no-op without debugger support keeps the bytecode identical to upstream and avoids the performance overhead.

@saghul saghul mentioned this pull request Apr 15, 2026
@bnoordhuis
Copy link
Copy Markdown
Contributor

Having emit_source_loc_debug as a no-op without debugger support keeps the bytecode identical to upstream and avoids the performance overhead.

Yes, exactly. :-)

The reason I suggested it is because it lets the debugger be a runtime option instead of a compile-time option.

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Apr 16, 2026

Thanks @bnoordhuis, I now understand the intent! I've refactored the implementation to make the debugger a runtime option instead of a compile-time option:

Key changes:

  • Removed all #ifdef QJS_ENABLE_DEBUGGER guards and the CMake option entirely
  • OP_debug is now always defined in the opcode table
  • emit_source_loc_debug() checks ctx->debug_trace at runtime — when no trace handler is set, it's a no-op, so the emitted bytecode is identical to upstream with zero performance overhead
  • When a user calls JS_SetDebugTraceHandler(ctx, cb), subsequent JS compilation will emit OP_debug opcodes and the debugger becomes active
  • All interpreter/optimizer code that handles OP_debug is always compiled in (no #ifdef guards needed — if OP_debug isn't emitted, those code paths are never reached)

This means a single QuickJS binary can serve both as a normal engine and a debug-capable engine, controlled entirely at runtime by whether JS_SetDebugTraceHandler has been called before JS code is compiled.

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Apr 16, 2026

@dblnz A note about the DIRECT_DISPATCH issue you ran into in #119:

The earlier approach (from my fork's QJS_ENABLE_DEBUGGER branch) placed the debug callback at the top of the for(;;) loop, before SWITCH(pc):

restart:
    for(;;) {
#ifdef QJS_ENABLE_DEBUGGER
        if (b && ctx->bytecode_trace != NULL) {
            // callback here...
        }
#endif
        SWITCH(pc) {
        CASE(OP_push_i32): ...

This had a fundamental problem with DIRECT_DISPATCH: when enabled (Linux/GCC), BREAK is defined as SWITCH(pc) which uses computed goto (goto *dispatch_table[*pc++]) to jump directly to the next opcode's label — completely bypassing the loop top where the callback lived. Only the very first instruction would trigger the callback. That's why the old approach had to force DIRECT_DISPATCH=0:

#if defined(EMSCRIPTEN) || defined(_MSC_VER) || defined(QJS_ENABLE_DEBUGGER)
#define DIRECT_DISPATCH  0

The current OP_debug approach from @bnoordhuis is fundamentally better — the debug logic lives inside CASE(OP_debug):, which is a proper entry in the dispatch table. Whether using switch/case or computed goto, OP_debug is dispatched just like any other opcode. No need to disable DIRECT_DISPATCH, so there is zero performance impact on the dispatch mechanism when debugging is not active.

This is why this PR's approach is superior to what I originally proposed in #119.

@dblnz
Copy link
Copy Markdown

dblnz commented Apr 22, 2026

@dblnz A note about the DIRECT_DISPATCH issue you ran into in #119:

The earlier approach (from my fork's QJS_ENABLE_DEBUGGER branch) placed the debug callback at the top of the for(;;) loop, before SWITCH(pc):

restart:
    for(;;) {
#ifdef QJS_ENABLE_DEBUGGER
        if (b && ctx->bytecode_trace != NULL) {
            // callback here...
        }
#endif
        SWITCH(pc) {
        CASE(OP_push_i32): ...

This had a fundamental problem with DIRECT_DISPATCH: when enabled (Linux/GCC), BREAK is defined as SWITCH(pc) which uses computed goto (goto *dispatch_table[*pc++]) to jump directly to the next opcode's label — completely bypassing the loop top where the callback lived. Only the very first instruction would trigger the callback. That's why the old approach had to force DIRECT_DISPATCH=0:

#if defined(EMSCRIPTEN) || defined(_MSC_VER) || defined(QJS_ENABLE_DEBUGGER)
#define DIRECT_DISPATCH  0

The current OP_debug approach from @bnoordhuis is fundamentally better — the debug logic lives inside CASE(OP_debug):, which is a proper entry in the dispatch table. Whether using switch/case or computed goto, OP_debug is dispatched just like any other opcode. No need to disable DIRECT_DISPATCH, so there is zero performance impact on the dispatch mechanism when debugging is not active.

This is why this PR's approach is superior to what I originally proposed in #119.

This sounds great, thanks!

@saghul
Copy link
Copy Markdown
Contributor

saghul commented Apr 23, 2026

@G-Yong looks like there are some conflicts with master now, could you please resolve them? 🙏

@saghul
Copy link
Copy Markdown
Contributor

saghul commented Apr 24, 2026

Asked Claude for a review:

 Adversarial Review: Debug Trace API Branch

  Audited the 60-commit branch that adds JS_SetDebugTraceHandler / OP_debug / JS_GetLocalVariablesAtLevel plus a one-line scanner
  tweak in next_token. All bugs below are reproduced with compiled C programs against the actual build.

  ---
  BUG: goto exception without a thrown exception leaks JS_UNINITIALIZED to callers
  File: quickjs.c:17697
  Category: Error Handling / Data Integrity
  Severity: CRITICAL

  When the callback returns non-zero without calling JS_Throw*, the OP_debug handler does goto exception, but rt->current_exception is
   still JS_UNINITIALIZED (or stale). JS_GetException then returns this internal sentinel to user code. Stringifying yields the
  literal debug text [uninitialized].

  Trigger:
  static int cb(...) { return -1; }  /* no JS_Throw */
  ...
  JSValue r = JS_Eval(ctx, "42", 2, "<t>", JS_EVAL_TYPE_GLOBAL);
  /* JS_IsException(r) = 1 */
  JSValue e = JS_GetException(ctx);
  /* JS_VALUE_GET_TAG(e) == JS_TAG_UNINITIALIZED (4); ToString(e) = "[uninitialized]" */
  Confirmed on the built binary.

  Fix: before goto exception, ensure an exception is set. Either throw a default InternalError("aborted by debugger") when the
  callback returned non-zero but JS_HasException(ctx) == false, or document and enforce that the callback must throw before returning
  non-zero.

  ---
  BUG: Callback can silently corrupt runtime exception state (throw + return 0)
  File: quickjs.c:17694-17699
  Category: Error Handling / State
  Severity: CRITICAL

  If the callback calls JS_Throw* but returns 0, the OP_debug handler does NOT goto exception — it falls through to BREAK and the next
   opcode runs. rt->current_exception is now set and nothing unwinds. Subsequent opcodes execute with a pending exception;
  JS_HasException(ctx) stays true across the next eval.

  Trigger:
  static int cb(JSContext *ctx, ...) {
      JS_ThrowInternalError(ctx, "oops");
      return 0;
  }
  JSValue r = JS_Eval(ctx, "let a=1; let b=2;", 17, ...);
  /* JS_IsException(r) == 0  -- eval "succeeded" */
  /* JS_HasException(ctx) == 1 -- BUT exception still pending */
  /* Next eval also ends with HasException == 1 */
  Confirmed.

  Fix: after the callback returns, check JS_HasException(ctx). If true, treat it as a non-zero return regardless of the numeric return
   value: goto exception. Callback’s return code should not be the only signal.

  ---
  BUG: Duplicate callback emission for every let and const declaration
  File: quickjs.c:28450, quickjs.c:28457
  Category: Logic Errors (fall-through)
  Severity: HIGH

  case TOK_LET:
  case TOK_CONST:
  haslet:
      emit_source_loc_debug(s);     // fires
      ...
      /* fall thru */
  case TOK_VAR:
      emit_source_loc_debug(s);     // fires AGAIN when entered via LET/CONST

  Entering via TOK_LET or TOK_CONST falls through to TOK_VAR and emits a second OP_source_loc+OP_debug pair for the same statement.
  The callback fires twice with identical line/col.

  Trigger: let x = 42; → callback fires twice (count=2); var x = 42; correctly fires once. Verified on built binary.

  Fix: move the emit_source_loc_debug(s) call from the haslet: label to after the fall-through target so it runs exactly once:
  case TOK_LET:
  case TOK_CONST:
  haslet:
      if (!(decl_mask & DECL_MASK_OTHER)) { ... }
      /* fall thru */
  case TOK_VAR:
      emit_source_loc_debug(s);

  ---
  BUG: Callback fires multiple times per statement for any expression with binary ops / calls / new / throw
  File: quickjs.c:23287-23288 (and callers at 26507, 26624, 27331, 28440, 29095)
  Category: Logic Errors
  Severity: HIGH

  emit_source_loc unconditionally appends OP_debug when a handler is set at parse time — but emit_source_loc is called inside
  expression parsers (js_parse_expr_binary, js_parse_postfix_expr, etc.), not only at statement boundaries. So a single statement
  emits multiple OP_debugs.

  The header comment claims "OP_debug opcodes are always emitted at statement boundaries" — this is false.

  Trigger: 1+2; → callback fires 2 times (statement start + binary op). a+b*c; would fire 3+ times. Inside a for loop, each test
  expression re-fires the callback per iteration. Verified: for (var i=0; i<3; i++) { var x=i; } produces 8 callback events.

  Fix: append OP_debug only inside emit_source_loc_debug, not inside the raw emit_source_loc. Revert the if
  (unlikely(s->ctx->debug_trace)) dbuf_putc(bc, OP_debug); inside emit_source_loc and keep the OP_debug emission confined to
  emit_source_loc_debug.

  ---
  BUG: Handler attached after parse-time is silently a no-op
  File: quickjs.c:23291-23294 (emit_source_loc_debug), quickjs.c:23287-23288
  Category: Logic Errors
  Severity: HIGH

  OP_debug emission is gated at PARSE time by s->ctx->debug_trace. Code parsed before the handler is installed has no OP_debug opcodes
   and cannot be instrumented retroactively. The public doc comment says "OP_debug opcodes are always emitted at statement boundaries"
   — false.

  Trigger:
  JS_Eval(ctx, "function f() { var a=1; var b=2; return a+b; }", ...);  /* no handler */
  JS_SetDebugTraceHandler(ctx, cb);
  JS_Eval(ctx, "f()", ...);   /* handler is set, but count=1: only the f() site, */
                              /* nothing inside f because f was parsed handler-less */
  Verified. Any real "attach debugger" flow breaks.

  Fix: two reasonable options. (a) Change the gate to runtime: emit OP_debug unconditionally at statement boundaries (cost: a no-op
  opcode per statement in non-debug runs). (b) Explicitly document that JS_SetDebugTraceHandler only affects code PARSED after the
  call, and consider making it a per-runtime or JS_EVAL_FLAG_* option so users understand the constraint.

  ---
  BUG: JS_GetLocalVariablesAtLevel leaks JS_UNINITIALIZED values to C callers
  File: quickjs.c:2629-2644
  Category: Data Integrity / Security
  Severity: HIGH

  let/const variables before their declaration sit in the temporal dead zone as JS_UNINITIALIZED (tag 4) in sf->var_buf[i]. The code
  js_dups this directly and hands it back to the caller. JS_UNINITIALIZED is an internal sentinel that should never escape the VM;
  operations on it produce undefined-behavior-ish output (e.g., ToString returns the debug string [uninitialized]) and may trip
  assertions in other QuickJS APIs.

  Trigger:
  function f() { let a; let b = 10; return a; }   // OP_debug fires before 'a' is initialized
  // Callback sees: var a tag=4 (JS_TAG_UNINITIALIZED)
  Verified on built binary.

  Fix: in the loop, substitute JS_UNINITIALIZED slots with JS_UNDEFINED (or a dedicated <uninitialized> sentinel the API documents),
  or skip those vars. Also skip compiler-generated names starting with < (e.g., <ret>, <this_active_func>, etc.) — the test above
  shows <ret> being exposed, which is an internal eval-result slot that has no business appearing in a debugger listing.

  ---
  BUG: JS_GetLocalVariablesAtLevel leaks exceptions from JS_AtomToCString failure
  File: quickjs.c:2629-2644
  Category: Error Handling
  Severity: MEDIUM

  JS_AtomToCString allocates; on OOM it sets rt->current_exception and returns NULL. The loop does NOT check the return value, nor
  does it clear the exception before returning. Caller gets back a partially-populated array with NULL names AND a pending runtime
  exception that will surface on the next API call.

  Trigger: any low-memory condition while the callback is running.

  Fix: on JS_AtomToCString failure inside the loop, free what was allocated so far, clear the exception (JS_FreeValue(ctx,
  JS_GetException(ctx))), return NULL with *pcount = 0.

  ---
  BUG: find_jump_target skips OP_debug, so jump optimizations bypass debug points
  File: quickjs.c:34237-34242 (find_jump_target), quickjs.c:34196-34207 (code_has_label), quickjs.c:33739-33745 (get_label_pos)
  Category: Logic Errors (optimizer)
  Severity: MEDIUM

  find_jump_target iterates past OP_source_loc, OP_label, AND OP_debug when resolving a goto's destination. This is the right call for
   OP_label/OP_source_loc (they are no-ops / metadata), but OP_debug is an executable opcode with side effects (invoking the user
  callback). Skipping it at optimization time means an OP_goto L whose label points to label: OP_debug; OP_real; gets rewritten to
  jump directly to OP_real, silently dropping the debug trace event.

  Trigger: any backward goto in a loop targeting a statement that starts with OP_debug — e.g., the continue edge of a for/while loop,
  when the loop body's first opcode is an OP_debug. Hard to construct a clean visible symptom in small cases because the initial
  OP_source_loc fires at a related position, but the guaranteed per-iteration fire at a labeled statement becomes a no-op.

  Fix: in find_jump_target/code_has_label, do NOT skip OP_debug. Treat it like any other real opcode — stop the skip loop when
  encountered. Leave the OP_source_loc skip alone.

  ---
  BUG: emit_source_loc_debug(s) after emit_break / before emit_label(label_finally) / after OP_throw is dead code
  File: quickjs.c:28736 (break/continue), quickjs.c:28954 (try/catch before label_finally)
  Category: Logic Errors
  Severity: LOW

  Line 28736: emit_break already emitted an unconditional OP_goto; the following emit_source_loc_debug(s) emits into unreachable
  bytecode.
  Line 28954: the emission comes after emit_op(s, OP_throw) (from the preceding catch/finally-only paths) and before emit_label(s,
  label_finally) — unreachable.

  Dead opcodes pollute pc2line tables and grow bytecode size for no effect.

  Fix: remove both calls. The existing trace at the start of the try/catch/finally and inside the finally block already covers the
  user-observable boundaries.

  ---
  BUG: emit_source_loc_debug before final OP_ret uses a stale source location
  File: quickjs.c:28990-28991
  Category: Logic Errors
  Severity: LOW

  emit_source_loc_debug(s);
  emit_op(s, OP_ret);

  At this point the parser has already advanced past the finally block's closing }; s->token.line_num/col_num point to whatever
  follows the try statement. The debug event therefore reports an unrelated later location as the "finally exit" trace point.

  Fix: drop this call, or capture line/col before parsing the finally block and restore before emit.

  ---
  BUG: Tab-indented line in a space-indented file
  File: quickjs.c:22574

  At this point the parser has already advanced past the finally block's closing }; s->token.line_num/col_num point to whatever
  follows the try statement. The debug event therefore reports an unrelated later location as the "finally exit" trace point.

  Fix: drop this call, or capture line/col before parsing the finally block and restore before emit.

  ---
  BUG: Tab-indented line in a space-indented file
  File: quickjs.c:22574
  Category: Style (but indicative of careless integration)
  Severity: LOW

                                s->col_num = max_int(1, s->mark - s->eol);
  Uses hard tabs in the middle of a function that indents with spaces. Mixed indentation will misalign in most editors. Also, this is
  a spot-fix for only one of several js_parse_error sites inside next_token where s->col_num is stale (see 22794 for the proper
  end-of-next_token update) — other error paths in the same function still report stale column numbers.

  Fix: re-indent with spaces. Consider calling a helper js_set_current_col(s) at all js_parse_error sites inside next_token, not just
  this one.

  ---
  BUG: Silent truncation of filename/funcname to 63 bytes in the callback
  File: quickjs.c:17690-17693
  Category: Data Integrity
  Severity: LOW

  ATOM_GET_STR_BUF_SIZE == 64. A filename longer than 63 bytes (common for absolute paths) or an unusually long function name gets
  silently truncated without indication. Debugger clients that match on filename will mis-match.

  Fix: either use JS_AtomToCString (heap-allocated, full string) and free after the callback, or document the 63-byte truncation in
  the API header. Also: document that the const char * pointers are only valid during the callback invocation — the current doc
  comment doesn’t say this, and a reasonable user would assume they can stash the pointer.

  ---
  BUG: Callback return-value codepath contradicts the header comment
  File: quickjs.h:545-548
  Category: Documentation / Contract
  Severity: LOW (but interacts with the CRITICAL bugs above)

  Header says: Return 0 to continue, non-zero to raise an exception. In reality non-zero does NOT raise any exception — it just jumps
  to the exception handler and leaves rt->current_exception untouched (which is the CRITICAL bug above). The caller must JS_Throw*
  themselves if they want a real exception.

  Fix: either make the implementation honor the doc (raise a default InternalError on non-zero-without-throw), or rewrite the comment
  to say "caller is responsible for calling JS_Throw* before returning non-zero; the engine will not synthesize an exception."

  ---

At a quick glance, some of these are legit.

G-Yong added a commit to G-Yong/quickjs that referenced this pull request Apr 25, 2026
Apply fixes for the issues raised in the latest adversarial review on the
"Add Debugging Interface" PR (quickjs-ng#1421):

* OP_debug callback (JS_CallInternal):
  - Use JS_AtomToCString instead of a 64-byte stack buffer so filename and
    funcname are passed to the trace handler at full length.
  - When the callback returns non-zero without raising an exception, throw
    a default InternalError("aborted by debugger") so JS_GetException()
    never returns the JS_UNINITIALIZED sentinel.
  - When the callback calls JS_Throw* but returns 0, also unwind to the
    exception handler so a pending exception is never silently carried
    over into the next opcode / next eval.

* JS_GetLocalVariablesAtLevel:
  - Skip compiler-generated names starting with '<' (e.g. <ret>,
    <this_active_func>) so they no longer appear in debugger listings.
  - Substitute JS_UNINITIALIZED slots (let/const TDZ) with JS_UNDEFINED
    so the internal sentinel never escapes to C callers.
  - On JS_AtomToCString OOM, free what was allocated, clear the pending
    exception, set *pcount = 0 and return NULL instead of leaving a
    half-built array and a leaked exception state.
  - Forward-declare JS_AtomGetStr so the helper macro can use it (it is
    defined later in the file).

* emit_source_loc / emit_source_loc_debug:
  - Stop appending OP_debug from inside emit_source_loc; that function is
    invoked from expression parsers and was emitting multiple OP_debug
    per statement. OP_debug is now confined to emit_source_loc_debug,
    which is only called at statement boundaries.
  - Move the emit_source_loc_debug for TOK_LET / TOK_CONST out of the
    'haslet:' label so the let/const fall-through into TOK_VAR no longer
    fires the trace twice.
  - Add emit_source_loc_debug at the top of throw and default expression
    statements where it was missing.
  - Drop dead emit_source_loc_debug calls after emit_break, before
    label_finally and before the final OP_ret of a try/catch/finally.

* find_jump_target / code_has_label:
  - Stop skipping OP_debug while resolving jump targets / scanning for
    labels. OP_debug is an executable opcode with a side effect (the user
    callback) and must not be jumped over by the optimizer.

* next_token: replace a stray hard-tab indent with spaces.

quickjs.h: rewrite the JSDebugTraceFunc / JS_SetDebugTraceHandler doc
comments to match the implementation contract:

* return non-zero OR raising via JS_Throw* both abort execution; the
  engine synthesizes a default exception when needed;
* the filename/funcname pointers are only valid for the duration of the
  callback invocation;
* OP_debug is only emitted for code parsed AFTER JS_SetDebugTraceHandler
  has been installed, so the handler must be set before evaluating any
  application code.
@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Apr 25, 2026

Thanks for the thorough review @saghul (and Claude)! I've pushed 1011587 on top of the branch addressing the issues raised. Summary:

OP_debug callback (JS_CallInternal)

  • Use JS_AtomToCString instead of a 64-byte stack buffer so filename / funcname are passed at full length (fixes the silent truncation bug).
  • If the callback returns non-zero without raising an exception, the engine now throws a default InternalError("aborted by debugger"), so JS_GetException() never returns the JS_UNINITIALIZED sentinel.
  • If the callback calls JS_Throw* but returns 0, we still goto exception (checked via JS_HasException), so a pending exception can never silently leak into the next opcode / next eval.

JS_GetLocalVariablesAtLevel

  • Skip compiler-generated names starting with < (e.g. , <this_active_func>).
  • Substitute JS_UNINITIALIZED slots (let/const TDZ) with JS_UNDEFINED so the internal sentinel never escapes to C callers.
  • On JS_AtomToCString OOM, free what was already allocated, clear the pending exception, set *pcount = 0 and return NULL (no more leaked exception state).

Duplicate / stray OP_debug emission

  • Removed the unconditional dbuf_putc(bc, OP_debug) from emit_source_loc — it was firing inside expression parsers and producing multiple OP_debug per statement. OP_debug is now only emitted from emit_source_loc_debug, which is called strictly at statement boundaries.
  • Moved the emit_source_loc_debug for TOK_LET / TOK_CONST out of the haslet: label so the fall-through into TOK_VAR no longer fires the trace twice for let/const.
  • Added the missing emit_source_loc_debug at the top of throw and the default expression-statement path.
  • Dropped dead emit_source_loc_debug calls after emit_break, before label_finally, and before the final OP_ret of try/catch/finally (those sites were unreachable / used stale source positions).

Optimizer

  • find_jump_target and code_has_label no longer skip OP_debug. It is an executable opcode with a side effect (the user callback) and must not be elided when the optimizer follows a jump.

Misc

  • Re-indented the stray hard-tab line in next_token with spaces.

quickjs.h

  • Rewrote the doc comments on JSDebugTraceFunc / JS_SetDebugTraceHandler to match the implementation contract: either a non-zero return or raising via JS_Throw* aborts execution; the engine synthesizes a default exception when needed; filename/funcname are only valid for the duration of the callback; and OP_debug is only emitted for code parsed after the handler has been installed (so the handler must be set before evaluating application code).

I have not yet tackled the broader "handler attached after parse-time is silently a no-op" point as a behavior change — that one is now explicitly documented in the header. Happy to also implement option (a) (always emit OP_debug at statement boundaries, gate the dispatch at runtime) if you'd prefer that over the documented contract.

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented Apr 25, 2026

Before changing the design I ran the web-tooling-benchmark v0.5.3 suite to compare both options. Setup: Windows / MSVC 2019 Release / x64, upstream c707cf5, three back-to-back runs each, geomean of runs/s.

Option (a) — always emit OP_source_loc + OP_debug, gate at runtime in OP_debug case

  • Upstream: 1.39 / 1.38 / 1.32 → 1.363
  • Fork (always-emit): 1.28 / 1.22 / 1.23 → 1.245
  • Geomean regression: −8.7 %
  • Worst hits: prepack −16.2 %, jshint −14.5 %, postcss −13.1 %, babylon −11.7 %, terser −11.4 %, esprima −10.6 %. Every project regressed.

Current PR design — parse-time gate (emit_source_loc_debug checks ctx->debug_trace once, at parse time)

  • Upstream: 1.34 / 1.38 / 1.36 → 1.360
  • Fork (gated): 1.32 / 1.34 / 1.36 → 1.340
  • Geomean Δ: −1.5 % — i.e. within run-to-run noise; per-benchmark deltas are roughly half positive, half negative.

@saghul
Copy link
Copy Markdown
Contributor

saghul commented May 18, 2026

Can you rebase one last time please?

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented May 18, 2026

I’ve used the ‘Sync fork’ option on GitHub. It seems to be just a merge operation. You could ‘merge --squash’ my pull request, as there are too many changes to make a rebase feasible. 😂

@saghul
Copy link
Copy Markdown
Contributor

saghul commented May 18, 2026

👍 can you take another look to see if more changes are necessary after explicit context management landed?

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented May 19, 2026

👍 can you take another look to see if more changes are necessary after explicit context management landed?

For the time being, we have made all the changes we could think of and had them reviewed by the AI. To the best of our ability and experience, the revisions are now largely complete.

Copy link
Copy Markdown
Contributor

@saghul saghul left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed with tuicr.

Comment thread api-test.c Outdated
Comment thread api-test.c Outdated
Comment thread quickjs-opcode.h Outdated
Comment thread quickjs.c Outdated
Comment thread quickjs.c Outdated
Comment thread quickjs.c Outdated
Comment thread quickjs.c Outdated
Comment thread quickjs.c
Comment thread quickjs.c Outdated
Comment thread quickjs.h Outdated
@saghul saghul dismissed their stale review May 19, 2026 07:25

There are still changes necessary.

G-Yong added a commit to G-Yong/quickjs that referenced this pull request May 19, 2026
api-test.c:
- Align debug_trace_cb argument indentation with the opening parenthesis.
- Update JS_GetLocalVariablesAtLevel call sites to the new signature
  (returns int, takes JSDebugLocalVar **pvars out parameter).
- Add an out-of-callback test: calling JS_GetLocalVariablesAtLevel
  when no frame is active must succeed and set pvars=NULL, count=0.
- Drop comments that merely restate what the code already says.

quickjs-opcode.h:
- Drop trailing inline comment on DEF(debug, ...).

quickjs.c:
- Move the JS_AtomGetStr forward declaration to the main forward-
  declaration block instead of placing it next to its first use.
- Remove comments above js_get_stack_frame_at_level and JS_GetStackDepth
  that add no information beyond the function names.
- Change JS_GetLocalVariablesAtLevel to return int (0 = ok, -1 =
  exception) and add a JSDebugLocalVar **pvars out parameter so callers
  can distinguish "no variables / no active frame" (returns 0, NULL)
  from a real OOM error (returns -1, exception pending). The OOM path
  no longer clears the pending exception so the caller can inspect it.
- Pass true/false instead of 1/0 for the is_arg argument of APPEND_VAR.
- Free the vars allocation when all entries were filtered out (idx==0)
  so the array is never returned with a zero count.
- Extract emit_debug() from emit_source_loc_debug(). At the throw
  statement and the default expression-statement path, where
  emit_source_loc() is already called unconditionally, use
  emit_source_loc() + emit_debug() instead of emit_source_loc_debug()
  to avoid emitting a duplicate OP_source_loc opcode.

quickjs.h:
- Change JSDebugLocalVar::is_arg from int to bool.
- Remove field comments that just restate the field names.
- Update JS_GetLocalVariablesAtLevel declaration and its doc comment
  to match the new int-return / out-parameter contract.
@saghul
Copy link
Copy Markdown
Contributor

saghul commented May 21, 2026

The commit fixing the column numbers landed! Can you tell the clanker to rebase / merge?

@saghul
Copy link
Copy Markdown
Contributor

saghul commented May 21, 2026

Btw, you can cleanup your branch like so:

git checkout your-branch
git fetch origin
git reset --soft $(git merge-base origin/master HEAD)
git commit -m "Add debugger interface"
git push --force

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented May 21, 2026

Btw, you can cleanup your branch like so:

git checkout your-branch
git fetch origin
git reset --soft $(git merge-base origin/master HEAD)
git commit -m "Add debugger interface"
git push --force

Got it. I really appreciate your patient guidance. I’ll sort it out tomorrow. (Today is a bit tight for me – hope you understand. 🙏)

@saghul
Copy link
Copy Markdown
Contributor

saghul commented May 21, 2026

No rush, thank you!

@G-Yong
Copy link
Copy Markdown
Author

G-Yong commented May 21, 2026

@saghul It’s done. I’ve finished the rebase.

Comment thread quickjs.c
Comment on lines +17836 to +17854
/* Use JS_AtomToCString to get the full filename / funcname
without the 63-byte truncation that a stack buffer would
impose. The pointers are only valid for the duration of
the callback. */
const char *filename = JS_AtomToCString(ctx, b->filename);
if (unlikely(!filename)) {
/* OOM: a pending exception has been raised */
goto exception;
}
const char *funcname = JS_AtomToCString(ctx, b->func_name);
if (unlikely(!funcname)) {
JS_FreeCString(ctx, filename);
goto exception;
}
int ret = ctx->debug_trace(ctx, filename, funcname,
line_num, col_num,
ctx->debug_trace_opaque);
JS_FreeCString(ctx, filename);
JS_FreeCString(ctx, funcname);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the API use JSAtom instead of char * for filename and funcname?
It would be up to the embedder to convert those to char * if they need to.
When debugging is enabled this is done for every statement and can be costly.
The embedder could choose to compare the JSAtom and avoid the allocation and convertion cost.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good idea! What are you thinking, that the embedded would perhaps keep a LRU cache of atoms?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking on a hashmap of breakpoints keyed by the tuple (JSAtom, line, column), and trying to reduce the cost of not hitting a breakpoint.
It would also be nice to have an API to query valid breakpoint locations, but that should be a different PR.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to eventually be able to implement the Chrome devtools debugger protocol using the debugger hooks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC that's what the original author did? Is that how the VSCode extension works @G-Yong ?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed although there are some of the APIs missing, it would be nice if we could complete as much as it makes sense for quickjs

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, do you have specific suggestions?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this PR is a good start. Other things that would be nice to have is setting variable values on different stack frames, capturing closure variables in the stack (not just locals), eval-ing in a stack frame. Also the devtools debugger protocol has hooks for script parsed, and script failed to parse.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, support for the debugger statement :-D

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.

6 participants