Add Debugging Interface#1421
Conversation
|
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? |
|
Thanks for the feedback @saghul! I've added a compile-time macro Compile-time gating (
|
|
@bnoordhuis WDYT? |
bnoordhuis
left a comment
There was a problem hiding this comment.
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?
|
Thanks for the review @bnoordhuis! I've addressed all your feedback: 1. API functions always availableType definitions
2. emit_source_loc_debugAdded 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 3. Renamed JSOPChangedHandler →
|
| 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.
|
I'm reasonably confident we can support this at runtime, no compile-time flags, without significant slowdowns when the debugger is inactive:
Proof of concept, very WIP: bnoordhuis/quickjs@f5fbb5b |
|
Thanks @bnoordhuis for the 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
Additional work beyond the POCSince the POC was intentionally minimal ("very WIP"), I filled in the remaining pieces:
Where we diverged (and why)Callback signatureYour 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
|
saghul
left a comment
There was a problem hiding this comment.
Left some comments! I like where this is going!
|
Since the bytecode needs to be regeneratred, can you bump the BC_VERSION please? You'll need to update the fuxxing tests too. |
|
Done — bumped |
|
Any chance you can add a small test in api-test.c ? |
|
Added a
|
|
Looks like you didn't regenerate the bundled bytecode after bumping it, and tests are failing due to that. |
|
Fixed — ran 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. |
|
@bnoordhuis any chance you can take a look? |
|
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? |
|
Ah sorry I think that's my bad, I misunderstood what you meant 😢 |
Okay, I will first conduct a comparative test based on the tools you provided to see the actual impact. |
Web Tooling Benchmark Performance Test ResultsI ran web-tooling-benchmark (17 real-world JavaScript workloads) to measure the performance impact of this PR's Test Environment
Detailed Results (runs/s, higher is better)
Raw Run DataUpstream: 1.43, 1.42, 1.45 (avg: 1.433) SummaryThe unconditional |
|
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. |
|
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? |
|
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. |
Yes, exactly. :-) The reason I suggested it is because it lets the debugger be a runtime option instead of a compile-time option. |
|
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:
This means a single QuickJS binary can serve both as a normal engine and a debug-capable engine, controlled entirely at runtime by whether |
|
@dblnz A note about the The earlier approach (from my fork's 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 #if defined(EMSCRIPTEN) || defined(_MSC_VER) || defined(QJS_ENABLE_DEBUGGER)
#define DIRECT_DISPATCH 0
The current This is why this PR's approach is superior to what I originally proposed in #119. |
This sounds great, thanks! |
|
@G-Yong looks like there are some conflicts with master now, could you please resolve them? 🙏 |
|
Asked Claude for a review: At a quick glance, some of these are legit. |
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.
|
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)
JS_GetLocalVariablesAtLevel
Duplicate / stray OP_debug emission
Optimizer
Misc
quickjs.h
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. |
|
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
Current PR design — parse-time gate (emit_source_loc_debug checks ctx->debug_trace once, at parse time)
|
|
Can you rebase one last time please? |
|
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. 😂 |
|
👍 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. |
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.
|
The commit fixing the column numbers landed! Can you tell the clanker to rebase / merge? |
|
Btw, you can cleanup your branch like so: |
Got it. I really appreciate your patient guidance. I’ll sort it out tomorrow. (Today is a bit tight for me – hope you understand. 🙏) |
|
No rush, thank you! |
|
@saghul It’s done. I’ve finished the rebase. |
| /* 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
That's a good idea! What are you thinking, that the embedded would perhaps keep a LRU cache of atoms?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
I would like to eventually be able to implement the Chrome devtools debugger protocol using the debugger hooks
There was a problem hiding this comment.
IIRC that's what the original author did? Is that how the VSCode extension works @G-Yong ?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Makes sense, do you have specific suggestions?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Also, support for the debugger statement :-D
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]