From 831130b1d75a2242727c9e0dbc6c99af0872a503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=AD=E8=8F=9C=E9=92=9F?= <312780179@qq.com> Date: Thu, 21 May 2026 21:25:06 +0800 Subject: [PATCH 1/5] Add debugger interface --- api-test.c | 126 ++++++++++++++++++++++++++++ quickjs-opcode.h | 2 + quickjs.c | 212 +++++++++++++++++++++++++++++++++++++++++++++++ quickjs.h | 56 +++++++++++++ 4 files changed, 396 insertions(+) diff --git a/api-test.c b/api-test.c index 7aa7dc8a6..ee80995f0 100644 --- a/api-test.c +++ b/api-test.c @@ -1013,6 +1013,131 @@ static void get_uint8array(void) JS_FreeRuntime(rt); } +static struct { + int call_count; + int last_line; + int last_col; + char last_filename[256]; + char last_funcname[256]; + int stack_depth; + int max_local_count; + int abort_at; /* abort (return -1) on this call, 0 = never */ +} trace_state; + +static int debug_trace_cb(JSContext *ctx, + const char *filename, + const char *funcname, + int line, + int col, + void *opaque) +{ + trace_state.call_count++; + trace_state.last_line = line; + trace_state.last_col = col; + snprintf(trace_state.last_filename, sizeof(trace_state.last_filename), + "%s", filename); + snprintf(trace_state.last_funcname, sizeof(trace_state.last_funcname), + "%s", funcname); + trace_state.stack_depth = JS_GetStackDepth(ctx); + int count = 0; + JSDebugLocalVar *vars = NULL; + assert(JS_GetLocalVariablesAtLevel(ctx, 0, &vars, &count) == 0); + if (count > trace_state.max_local_count) + trace_state.max_local_count = count; + if (vars) + JS_FreeLocalVariables(ctx, vars, count); + if (trace_state.abort_at > 0 && + trace_state.call_count >= trace_state.abort_at) + return -1; + return 0; +} + +static void debug_trace(void) +{ + JSRuntime *rt = JS_NewRuntime(); + JSContext *ctx = JS_NewContext(rt); + + memset(&trace_state, 0, sizeof(trace_state)); + { + JSValue ret = eval(ctx, "1+2"); + assert(!JS_IsException(ret)); + JS_FreeValue(ctx, ret); + assert(trace_state.call_count == 0); + } + + JS_SetDebugTraceHandler(ctx, debug_trace_cb, NULL); + memset(&trace_state, 0, sizeof(trace_state)); + { + JSValue ret = eval(ctx, "var x = 1; x + 2"); + assert(!JS_IsException(ret)); + JS_FreeValue(ctx, ret); + assert(trace_state.call_count > 0); + assert(!strcmp(trace_state.last_filename, "")); + } + + { + JSDebugLocalVar *vars = NULL; + int count = -1; + assert(JS_GetLocalVariablesAtLevel(ctx, 0, &vars, &count) == 0); + assert(vars == NULL); + assert(count == 0); + } + + memset(&trace_state, 0, sizeof(trace_state)); + { + static const char code[] = + "function outer() {\n" + " function inner() {\n" + " return 42;\n" + " }\n" + " return inner();\n" + "}\n" + "outer();\n"; + JSValue ret = eval(ctx, code); + assert(!JS_IsException(ret)); + JS_FreeValue(ctx, ret); + assert(trace_state.call_count > 0); + assert(trace_state.stack_depth >= 1); + } + + memset(&trace_state, 0, sizeof(trace_state)); + { + static const char code[] = + "function f(a, b) {\n" + " var c = a + b;\n" + " return c;\n" + "}\n" + "f(10, 20);\n"; + JSValue ret = eval(ctx, code); + assert(!JS_IsException(ret)); + JS_FreeValue(ctx, ret); + assert(trace_state.call_count > 0); + assert(trace_state.max_local_count >= 2); + } + + memset(&trace_state, 0, sizeof(trace_state)); + trace_state.abort_at = 1; + { + JSValue ret = eval(ctx, "1+2; 3+4"); + assert(JS_IsException(ret)); + JS_FreeValue(ctx, ret); + JSValue exc = JS_GetException(ctx); + JS_FreeValue(ctx, exc); + } + + JS_SetDebugTraceHandler(ctx, NULL, NULL); + memset(&trace_state, 0, sizeof(trace_state)); + { + JSValue ret = eval(ctx, "1+2"); + assert(!JS_IsException(ret)); + JS_FreeValue(ctx, ret); + assert(trace_state.call_count == 0); + } + + JS_FreeContext(ctx); + JS_FreeRuntime(rt); +} + static void new_symbol(void) { JSRuntime *rt = new_runtime(); @@ -1089,6 +1214,7 @@ int main(void) slice_string_tocstring(); immutable_array_buffer(); get_uint8array(); + debug_trace(); new_symbol(); return 0; } diff --git a/quickjs-opcode.h b/quickjs-opcode.h index ec2a5ad91..a454f836a 100644 --- a/quickjs-opcode.h +++ b/quickjs-opcode.h @@ -372,6 +372,8 @@ DEF( is_null, 1, 1, 1, none) DEF(typeof_is_undefined, 1, 1, 1, none) DEF( typeof_is_function, 1, 1, 1, none) +DEF( debug, 1, 0, 0, none) + #undef DEF #undef def #endif /* DEF */ diff --git a/quickjs.c b/quickjs.c index 38a724fbb..c46e5445a 100644 --- a/quickjs.c +++ b/quickjs.c @@ -539,6 +539,9 @@ struct JSContext { const char *input, size_t input_len, const char *filename, int line, int flags, int scope_idx); void *user_opaque; + + JSDebugTraceFunc *debug_trace; + void *debug_trace_opaque; }; typedef union JSFloat64Union { @@ -1390,6 +1393,7 @@ static void js_async_function_resolve_mark(JSRuntime *rt, JSValueConst val, static JSValue JS_EvalInternal(JSContext *ctx, JSValueConst this_obj, const char *input, size_t input_len, const char *filename, int line, int flags, int scope_idx); +static const char *JS_AtomGetStr(JSContext *ctx, char *buf, int buf_size, JSAtom atom); static void js_free_module_def(JSContext *ctx, JSModuleDef *m); static void js_mark_module_def(JSRuntime *rt, JSModuleDef *m, JS_MarkFunc *mark_func); @@ -2593,6 +2597,141 @@ JSValue JS_GetFunctionProto(JSContext *ctx) return js_dup(ctx->function_proto); } +void JS_SetDebugTraceHandler(JSContext *ctx, JSDebugTraceFunc *cb, void *opaque) +{ + ctx->debug_trace = cb; + ctx->debug_trace_opaque = opaque; +} + +static JSStackFrame *js_get_stack_frame_at_level(JSContext *ctx, int level) +{ + JSRuntime *rt = ctx->rt; + JSStackFrame *sf = rt->current_stack_frame; + int current_level = 0; + + while (sf != NULL && current_level < level) { + sf = sf->prev_frame; + current_level++; + } + return sf; +} + +int JS_GetStackDepth(JSContext *ctx) +{ + JSRuntime *rt = ctx->rt; + JSStackFrame *sf = rt->current_stack_frame; + int depth = 0; + + while (sf != NULL) { + depth++; + sf = sf->prev_frame; + } + return depth; +} + +int JS_GetLocalVariablesAtLevel(JSContext *ctx, int level, + JSDebugLocalVar **pvars, int *pcount) +{ + if (pvars) + *pvars = NULL; + if (pcount) + *pcount = 0; + if (!pvars) { + JS_ThrowTypeError(ctx, "pvars must not be NULL"); + return -1; + } + + JSStackFrame *sf = js_get_stack_frame_at_level(ctx, level); + if (sf == NULL) + return 0; + + JSValue func = sf->cur_func; + if (JS_VALUE_GET_TAG(func) != JS_TAG_OBJECT) + return 0; + + JSObject *p = JS_VALUE_GET_OBJ(func); + if (p->class_id != JS_CLASS_BYTECODE_FUNCTION) + return 0; + + JSFunctionBytecode *b = p->u.func.function_bytecode; + int total_vars = b->arg_count + b->var_count; + + if (total_vars == 0) + return 0; + + JSDebugLocalVar *vars = js_malloc(ctx, sizeof(JSDebugLocalVar) * total_vars); + if (!vars) + return -1; + + int idx = 0; + +#define APPEND_VAR(vd_, value_, is_arg_) \ + do { \ + JSAtom name_ = (vd_)->var_name; \ + const char *name_str_; \ + if (name_ != JS_ATOM_NULL) { \ + char tmp_[32]; \ + JS_AtomGetStr(ctx, tmp_, sizeof(tmp_), name_); \ + if (tmp_[0] == '<') \ + break; \ + } \ + name_str_ = JS_AtomToCString(ctx, name_); \ + if (unlikely(!name_str_)) \ + goto fail; \ + vars[idx].name = name_str_; \ + /* Do not expose the internal TDZ sentinel to C callers. */ \ + if (JS_VALUE_GET_TAG(value_) == JS_TAG_UNINITIALIZED) \ + vars[idx].value = JS_UNDEFINED; \ + else \ + vars[idx].value = js_dup(value_); \ + vars[idx].is_arg = (is_arg_); \ + vars[idx].scope_level = (vd_)->scope_level; \ + idx++; \ + } while (0) + + for (int i = 0; i < b->arg_count; i++) { + JSVarDef *vd = &b->vardefs[i]; + APPEND_VAR(vd, sf->arg_buf[i], true); + } + + for (int i = 0; i < b->var_count; i++) { + JSVarDef *vd = &b->vardefs[b->arg_count + i]; + APPEND_VAR(vd, sf->var_buf[i], false); + } + +#undef APPEND_VAR + + if (idx == 0) { + js_free(ctx, vars); + return 0; + } + + if (pvars) + *pvars = vars; + if (pcount) + *pcount = idx; + return 0; + +fail: + for (int i = 0; i < idx; i++) { + JS_FreeCString(ctx, vars[i].name); + JS_FreeValue(ctx, vars[i].value); + } + js_free(ctx, vars); + return -1; +} + +void JS_FreeLocalVariables(JSContext *ctx, JSDebugLocalVar *vars, int count) +{ + if (!vars) + return; + for (int i = 0; i < count; i++) { + JS_FreeCString(ctx, vars[i].name); + JS_FreeValue(ctx, vars[i].value); + } + js_free(ctx, vars); +} + typedef enum JSFreeModuleEnum { JS_FREE_MODULE_ALL, JS_FREE_MODULE_NOT_RESOLVED, @@ -17687,6 +17826,44 @@ static JSValue JS_CallInternal(JSContext *caller_ctx, JSValueConst func_obj, JSValue *call_argv; SWITCH(pc) { + CASE(OP_debug): + if (unlikely(ctx->debug_trace)) { + int col_num = 0; + int line_num = -1; + uint32_t pc_index = (uint32_t)(pc - b->byte_code_buf - 1); + line_num = find_line_num(ctx, b, pc_index, &col_num); + + /* 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); + + if (ret != 0 || JS_HasException(ctx)) { + /* If the callback indicated failure but did not raise + an exception itself, synthesize a default one so the + caller never observes JS_UNINITIALIZED via + JS_GetException(). */ + if (ret != 0 && !JS_HasException(ctx)) + JS_ThrowInternalError(ctx, "aborted by debugger"); + goto exception; + } + } + BREAK; CASE(OP_push_i32): *sp++ = js_int32(get_u32(pc)); pc += 4; @@ -23410,6 +23587,20 @@ static void emit_source_loc(JSParseState *s) emit_source_loc_at(s, s->token.line_num, s->token.col_num); } +static void emit_debug(JSParseState *s) +{ + if (unlikely(s->ctx->debug_trace)) + dbuf_putc(&s->cur_func->byte_code, OP_debug); +} + +static void emit_source_loc_debug(JSParseState *s) +{ + if (unlikely(s->ctx->debug_trace)) { + emit_source_loc(s); + emit_debug(s); + } +} + static void emit_op(JSParseState *s, uint8_t val) { JSFunctionDef *fd = s->cur_func; @@ -28774,6 +28965,7 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, goto fail; break; case TOK_RETURN: + emit_source_loc_debug(s); if (s->cur_func->is_eval) { js_parse_error(s, "return not in a function"); goto fail; @@ -28802,6 +28994,7 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, goto fail; } emit_source_loc(s); + emit_debug(s); if (js_parse_expr(s)) goto fail; emit_op(s, OP_throw); @@ -28825,6 +29018,7 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, goto fail; } s->cur_func->has_await = true; + emit_source_loc_debug(s); if (next_token(s)) /* skip 'using' */ goto fail; if (js_parse_var(s, PF_IN_ACCEPTED | PF_AWAIT_USING, TOK_USING, /*export_flag*/false)) @@ -28847,6 +29041,7 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, } /* fall thru */ case TOK_VAR: + emit_source_loc_debug(s); if (next_token(s)) goto fail; if (js_parse_var(s, PF_IN_ACCEPTED, tok, /*export_flag*/false)) @@ -28857,6 +29052,7 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, case TOK_IF: { int label1, label2, mask; + emit_source_loc_debug(s); if (next_token(s)) goto fail; /* create a new scope for `let f;if(1) function f(){}` */ @@ -28967,6 +29163,7 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, int source_line_num, source_col_num; bool is_async; + emit_source_loc_debug(s); source_line_num = s->token.line_num; source_col_num = s->token.col_num; if (next_token(s)) @@ -29202,6 +29399,7 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, int default_label_pos; BlockEnv break_entry; + emit_source_loc_debug(s); if (next_token(s)) goto fail; @@ -29553,6 +29751,7 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, js_parse_error(s, "using declaration is not allowed at the top level of a script"); goto fail; } + emit_source_loc_debug(s); if (next_token(s)) goto fail; if (js_parse_var(s, PF_IN_ACCEPTED, TOK_USING, /*export_flag*/false)) @@ -29606,6 +29805,7 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, default: hasexpr: emit_source_loc(s); + emit_debug(s); if (js_parse_expr(s)) goto fail; if (s->cur_func->eval_ret_idx >= 0) { @@ -33969,6 +34169,8 @@ static bool code_match(CodeContext *s, int pos, ...) line_num = get_u32(tab + pos + 1); col_num = get_u32(tab + pos + 5); pos = pos_next; + } else if (op == OP_debug) { + pos = pos_next; } else { break; } @@ -34256,6 +34458,9 @@ static int get_label_pos(JSFunctionDef *s, int label) case OP_source_loc: pos += 9; continue; + case OP_debug: + pos += 1; + continue; case OP_label: pos += 5; continue; @@ -35017,6 +35222,13 @@ static __exception int resolve_labels(JSContext *ctx, JSFunctionDef *s) col_num = get_u32(bc_buf + pos + 5); break; + case OP_debug: + /* record pc2line so the debugger can resolve the source + location when OP_debug is hit at runtime */ + add_pc2line_info(s, bc_out.size, line_num, col_num); + dbuf_putc(&bc_out, OP_debug); + break; + case OP_label: { label = get_u32(bc_buf + pos + 1); diff --git a/quickjs.h b/quickjs.h index b9ed27560..498ee1a2b 100644 --- a/quickjs.h +++ b/quickjs.h @@ -523,6 +523,62 @@ JS_EXTERN void JS_SetClassProto(JSContext *ctx, JSClassID class_id, JSValue obj) JS_EXTERN JSValue JS_GetClassProto(JSContext *ctx, JSClassID class_id); JS_EXTERN JSValue JS_GetFunctionProto(JSContext *ctx); +/* Debug callback - invoked when the interpreter hits an OP_debug opcode. + Return 0 to continue execution. Return non-zero to abort execution at + this point: the engine will jump to the exception handler. The + callback may itself call JS_Throw* to provide a specific exception; + if the callback returns non-zero without having raised one, the engine + will synthesize a default InternalError("aborted by debugger"). If + the callback raises an exception via JS_Throw* but returns 0, the + engine still treats it as a request to abort. + + The filename / funcname pointers passed to the callback are only valid + for the duration of the callback invocation; do not store them. + + OP_debug opcodes are only emitted at statement boundaries when a debug + trace handler is registered at parse time. Therefore only code that + is parsed (e.g. by JS_Eval / JS_Compile) AFTER JS_SetDebugTraceHandler + has been called will be instrumented; previously compiled bytecode + will not invoke the callback. In practice, install the handler before + evaluating any application code. */ +typedef int JSDebugTraceFunc(JSContext *ctx, + const char *filename, + const char *funcname, + int line, + int col, + void *opaque); + +/* Set (or clear) the debug trace handler on a context. Pass NULL to + disable. Works with any context, including those created with + JS_NewContextRaw. See JSDebugTraceFunc above for the parse-time + instrumentation contract. */ +JS_EXTERN void JS_SetDebugTraceHandler(JSContext *ctx, + JSDebugTraceFunc *cb, + void *opaque); + +/* Debug API: Get local variables in stack frames */ +typedef struct JSDebugLocalVar { + const char *name; + JSValue value; + bool is_arg; + int scope_level; +} JSDebugLocalVar; + +/* Get the call stack depth (0 when no frames are active). */ +JS_EXTERN int JS_GetStackDepth(JSContext *ctx); + +/* Get local variables at a specific stack level (0 = current frame, 1 = caller, etc.). + On success, *pvars receives an allocated array of JSDebugLocalVar entries + that must be freed with JS_FreeLocalVariables(), and *pcount receives the + entry count. If no variables are available, *pvars is set to NULL and + *pcount is set to 0. Returns -1 on exception. */ +JS_EXTERN int JS_GetLocalVariablesAtLevel(JSContext *ctx, int level, + JSDebugLocalVar **pvars, + int *pcount); + +/* Free local variables array returned by JS_GetLocalVariablesAtLevel */ +JS_EXTERN void JS_FreeLocalVariables(JSContext *ctx, JSDebugLocalVar *vars, int count); + /* the following functions are used to select the intrinsic object to save memory */ JS_EXTERN JSContext *JS_NewContextRaw(JSRuntime *rt); From 80733f94ace92cf5dc4ab972ac61790facab8729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=AD=E8=8F=9C=E9=92=9F?= <312780179@qq.com> Date: Fri, 22 May 2026 10:00:02 +0800 Subject: [PATCH 2/5] refactor: change filename and funcname parameters to JSAtom in debug trace functions suggestion form @jprendes at https://github.com/quickjs-ng/quickjs/pull/1421#discussion_r3282209707 --- api-test.c | 20 +++++++++++--------- quickjs.c | 22 +++++----------------- quickjs.h | 15 ++++++++++++--- 3 files changed, 28 insertions(+), 29 deletions(-) diff --git a/api-test.c b/api-test.c index ee80995f0..300f02f83 100644 --- a/api-test.c +++ b/api-test.c @@ -1017,16 +1017,16 @@ static struct { int call_count; int last_line; int last_col; - char last_filename[256]; - char last_funcname[256]; + JSAtom last_filename; + JSAtom last_funcname; int stack_depth; int max_local_count; int abort_at; /* abort (return -1) on this call, 0 = never */ } trace_state; static int debug_trace_cb(JSContext *ctx, - const char *filename, - const char *funcname, + JSAtom filename, + JSAtom funcname, int line, int col, void *opaque) @@ -1034,10 +1034,8 @@ static int debug_trace_cb(JSContext *ctx, trace_state.call_count++; trace_state.last_line = line; trace_state.last_col = col; - snprintf(trace_state.last_filename, sizeof(trace_state.last_filename), - "%s", filename); - snprintf(trace_state.last_funcname, sizeof(trace_state.last_funcname), - "%s", funcname); + trace_state.last_filename = filename; + trace_state.last_funcname = funcname; trace_state.stack_depth = JS_GetStackDepth(ctx); int count = 0; JSDebugLocalVar *vars = NULL; @@ -1072,7 +1070,11 @@ static void debug_trace(void) assert(!JS_IsException(ret)); JS_FreeValue(ctx, ret); assert(trace_state.call_count > 0); - assert(!strcmp(trace_state.last_filename, "")); + { + const char *fn = JS_AtomToCString(ctx, trace_state.last_filename); + assert(fn && !strcmp(fn, "")); + JS_FreeCString(ctx, fn); + } } { diff --git a/quickjs.c b/quickjs.c index c46e5445a..008856bc7 100644 --- a/quickjs.c +++ b/quickjs.c @@ -17833,25 +17833,13 @@ static JSValue JS_CallInternal(JSContext *caller_ctx, JSValueConst func_obj, uint32_t pc_index = (uint32_t)(pc - b->byte_code_buf - 1); line_num = find_line_num(ctx, b, pc_index, &col_num); - /* 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, + /* Pass the JSAtom values directly — no heap allocation. + The atoms are valid for the lifetime of the bytecode + object, which outlives the callback. The embedder must + call JS_DupAtom() if it needs to retain them. */ + int ret = ctx->debug_trace(ctx, b->filename, b->func_name, line_num, col_num, ctx->debug_trace_opaque); - JS_FreeCString(ctx, filename); - JS_FreeCString(ctx, funcname); if (ret != 0 || JS_HasException(ctx)) { /* If the callback indicated failure but did not raise diff --git a/quickjs.h b/quickjs.h index 498ee1a2b..55b3276a4 100644 --- a/quickjs.h +++ b/quickjs.h @@ -540,10 +540,19 @@ JS_EXTERN JSValue JS_GetFunctionProto(JSContext *ctx); is parsed (e.g. by JS_Eval / JS_Compile) AFTER JS_SetDebugTraceHandler has been called will be instrumented; previously compiled bytecode will not invoke the callback. In practice, install the handler before - evaluating any application code. */ + evaluating any application code. + + 'filename' and 'funcname' are JSAtom values identifying the source file + and enclosing function name. They are valid only for the duration of + the callback; call JS_DupAtom() if you need to retain them. Either + may be JS_ATOM_NULL (0) for anonymous functions or eval code. Use + JS_AtomToCString() / JS_AtomToString() to convert to a C string or + JSValue when needed. Accepting JSAtom avoids a heap allocation on + every instrumented statement when the embedder only needs to compare + against a known set of breakpoint locations. */ typedef int JSDebugTraceFunc(JSContext *ctx, - const char *filename, - const char *funcname, + JSAtom filename, + JSAtom funcname, int line, int col, void *opaque); From db61e3c2b2876f49fe645053e308d939ac309682 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=AD=E8=8F=9C=E9=92=9F?= <312780179@qq.com> Date: Fri, 22 May 2026 10:04:48 +0800 Subject: [PATCH 3/5] refactor: remove outdated comments regarding filename and funcname pointers in debug trace handler --- quickjs.h | 3 --- 1 file changed, 3 deletions(-) diff --git a/quickjs.h b/quickjs.h index 55b3276a4..0bec35988 100644 --- a/quickjs.h +++ b/quickjs.h @@ -532,9 +532,6 @@ JS_EXTERN JSValue JS_GetFunctionProto(JSContext *ctx); the callback raises an exception via JS_Throw* but returns 0, the engine still treats it as a request to abort. - The filename / funcname pointers passed to the callback are only valid - for the duration of the callback invocation; do not store them. - OP_debug opcodes are only emitted at statement boundaries when a debug trace handler is registered at parse time. Therefore only code that is parsed (e.g. by JS_Eval / JS_Compile) AFTER JS_SetDebugTraceHandler From c64d4894db5f1f97d9999da11ea34f6ef135b5ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=AD=E8=8F=9C=E9=92=9F?= <312780179@qq.com> Date: Fri, 22 May 2026 10:20:33 +0800 Subject: [PATCH 4/5] refactor: change last_filename and last_funcname from JSAtom to char arrays in debug trace Update api-test.c to match the new signature, converting to string inside the callback while the atoms are still guaranteed to be valid. --- api-test.c | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/api-test.c b/api-test.c index 300f02f83..403cfec49 100644 --- a/api-test.c +++ b/api-test.c @@ -1017,8 +1017,8 @@ static struct { int call_count; int last_line; int last_col; - JSAtom last_filename; - JSAtom last_funcname; + char last_filename[256]; + char last_funcname[256]; int stack_depth; int max_local_count; int abort_at; /* abort (return -1) on this call, 0 = never */ @@ -1034,8 +1034,21 @@ static int debug_trace_cb(JSContext *ctx, trace_state.call_count++; trace_state.last_line = line; trace_state.last_col = col; - trace_state.last_filename = filename; - trace_state.last_funcname = funcname; + /* Convert while the atom is still valid (within callback lifetime). + Embedders who only need to compare against known breakpoint atoms + can skip this conversion entirely. */ + const char *fn = JS_AtomToCString(ctx, filename); + if (fn) { + snprintf(trace_state.last_filename, sizeof(trace_state.last_filename), + "%s", fn); + JS_FreeCString(ctx, fn); + } + const char *fnn = JS_AtomToCString(ctx, funcname); + if (fnn) { + snprintf(trace_state.last_funcname, sizeof(trace_state.last_funcname), + "%s", fnn); + JS_FreeCString(ctx, fnn); + } trace_state.stack_depth = JS_GetStackDepth(ctx); int count = 0; JSDebugLocalVar *vars = NULL; @@ -1070,11 +1083,7 @@ static void debug_trace(void) assert(!JS_IsException(ret)); JS_FreeValue(ctx, ret); assert(trace_state.call_count > 0); - { - const char *fn = JS_AtomToCString(ctx, trace_state.last_filename); - assert(fn && !strcmp(fn, "")); - JS_FreeCString(ctx, fn); - } + assert(!strcmp(trace_state.last_filename, "")); } { From 9650f3fea48af371c5615a5b4b434ad46e854851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=AD=E8=8F=9C=E9=92=9F?= <312780179@qq.com> Date: Tue, 26 May 2026 09:37:52 +0800 Subject: [PATCH 5/5] add flags param to JSDebugTraceFunc, OP_debugger_stmt opcode, rewrite JS_EvalInStackFrame - JSDebugTraceFunc gains `int flags`; JS_DEBUG_TRACE_DEBUGGER_STMT (1<<0) is set when triggered by a `debugger;` statement - New OP_debugger_stmt opcode; parser emits it for `debugger;` instead of OP_debug; all optimizer/label passes updated accordingly - JS_EvalInStackFrame rewritten to use JS_EvalInternal + JS_EVAL_TYPE_DIRECT (temporarily swaps rt->current_stack_frame to target frame) instead of building a wrapper function string; correctly resolves let/const/closures - JS_GetLocalVariablesAtLevel now exposes closure vars (is_closure=true) - JS_SetVariableAtLevel added: writes to any named binding on a stack frame - api-test.c: update debug_trace_cb to new signature --- api-test.c | 2 + quickjs-opcode.h | 1 + quickjs.c | 193 ++++++++++++++++++++++++++++++++++++++++++++++- quickjs.h | 31 +++++++- 4 files changed, 222 insertions(+), 5 deletions(-) diff --git a/api-test.c b/api-test.c index 403cfec49..a61858e52 100644 --- a/api-test.c +++ b/api-test.c @@ -1029,8 +1029,10 @@ static int debug_trace_cb(JSContext *ctx, JSAtom funcname, int line, int col, + int flags, void *opaque) { + (void)flags; trace_state.call_count++; trace_state.last_line = line; trace_state.last_col = col; diff --git a/quickjs-opcode.h b/quickjs-opcode.h index a454f836a..ce954cbb9 100644 --- a/quickjs-opcode.h +++ b/quickjs-opcode.h @@ -373,6 +373,7 @@ DEF(typeof_is_undefined, 1, 1, 1, none) DEF( typeof_is_function, 1, 1, 1, none) DEF( debug, 1, 0, 0, none) +DEF( debugger_stmt, 1, 0, 0, none) #undef DEF #undef def diff --git a/quickjs.c b/quickjs.c index 008856bc7..cabfcb107 100644 --- a/quickjs.c +++ b/quickjs.c @@ -2654,7 +2654,7 @@ int JS_GetLocalVariablesAtLevel(JSContext *ctx, int level, return 0; JSFunctionBytecode *b = p->u.func.function_bytecode; - int total_vars = b->arg_count + b->var_count; + int total_vars = b->arg_count + b->var_count + b->closure_var_count; if (total_vars == 0) return 0; @@ -2685,6 +2685,7 @@ int JS_GetLocalVariablesAtLevel(JSContext *ctx, int level, else \ vars[idx].value = js_dup(value_); \ vars[idx].is_arg = (is_arg_); \ + vars[idx].is_closure = false; \ vars[idx].scope_level = (vd_)->scope_level; \ idx++; \ } while (0) @@ -2701,6 +2702,36 @@ int JS_GetLocalVariablesAtLevel(JSContext *ctx, int level, #undef APPEND_VAR + /* Append closure variables captured from enclosing scopes. */ + { + JSVarRef **var_refs = p->u.func.var_refs; + for (int i = 0; i < b->closure_var_count; i++) { + JSClosureVar *cv = &b->closure_var[i]; + JSAtom name = cv->var_name; + if (name != JS_ATOM_NULL) { + char tmp[32]; + JS_AtomGetStr(ctx, tmp, sizeof(tmp), name); + if (tmp[0] == '<') + continue; + } + const char *name_str = JS_AtomToCString(ctx, name); + if (unlikely(!name_str)) + goto fail; + JSValue cv_val = JS_UNDEFINED; + if (var_refs && var_refs[i] && var_refs[i]->pvalue) { + JSValue v = *var_refs[i]->pvalue; + if (JS_VALUE_GET_TAG(v) != JS_TAG_UNINITIALIZED) + cv_val = js_dup(v); + } + vars[idx].name = name_str; + vars[idx].value = cv_val; + vars[idx].is_arg = false; + vars[idx].is_closure = true; + vars[idx].scope_level = 0; + idx++; + } + } + if (idx == 0) { js_free(ctx, vars); return 0; @@ -2732,6 +2763,143 @@ void JS_FreeLocalVariables(JSContext *ctx, JSDebugLocalVar *vars, int count) js_free(ctx, vars); } +int JS_SetVariableAtLevel(JSContext *ctx, int level, + const char *name, JSValue value) +{ + if (!name) { + JS_FreeValue(ctx, value); + return -3; + } + + JSStackFrame *sf = js_get_stack_frame_at_level(ctx, level); + if (sf == NULL) { + JS_FreeValue(ctx, value); + return -1; + } + + JSValue func = sf->cur_func; + if (JS_VALUE_GET_TAG(func) != JS_TAG_OBJECT) { + JS_FreeValue(ctx, value); + return -1; + } + JSObject *p = JS_VALUE_GET_OBJ(func); + if (p->class_id != JS_CLASS_BYTECODE_FUNCTION) { + JS_FreeValue(ctx, value); + return -1; + } + JSFunctionBytecode *b = p->u.func.function_bytecode; + + JSAtom target_atom = JS_NewAtom(ctx, name); + if (target_atom == JS_ATOM_NULL) { + JS_FreeValue(ctx, value); + return -3; + } + + int rc = -1; + + /* Search arguments. */ + for (int i = 0; i < b->arg_count; i++) { + if (b->vardefs[i].var_name == target_atom) { + JSValue old = sf->arg_buf[i]; + sf->arg_buf[i] = js_dup(value); + JS_FreeValue(ctx, old); + rc = 0; + goto done; + } + } + + /* Search locals. */ + for (int i = 0; i < b->var_count; i++) { + JSVarDef *vd = &b->vardefs[b->arg_count + i]; + if (vd->var_name == target_atom) { + if (vd->is_const) { + rc = -2; + goto done; + } + JSValue old = sf->var_buf[i]; + sf->var_buf[i] = js_dup(value); + JS_FreeValue(ctx, old); + rc = 0; + goto done; + } + } + + /* Search closure vars. */ + { + JSVarRef **var_refs = p->u.func.var_refs; + for (int i = 0; i < b->closure_var_count; i++) { + JSClosureVar *cv = &b->closure_var[i]; + if (cv->var_name == target_atom) { + if (cv->is_const) { + rc = -2; + goto done; + } + if (var_refs && var_refs[i] && var_refs[i]->pvalue) { + set_value(ctx, var_refs[i]->pvalue, js_dup(value)); + rc = 0; + } + goto done; + } + } + } + +done: + JS_FreeAtom(ctx, target_atom); + JS_FreeValue(ctx, value); + return rc; +} + +JSValue JS_EvalInStackFrame(JSContext *ctx, int level, + const char *input, size_t input_len, + const char *filename) +{ + if (!input) + return JS_ThrowTypeError(ctx, "input must not be NULL"); + + /* Reuse the normal direct-eval pipeline: temporarily swap the runtime's + current stack frame to the target frame so that JS_EVAL_TYPE_DIRECT + picks up its bytecode, var_refs and closure chain via the same path + used by the `eval(...)` operator at runtime. */ + JSStackFrame *target = js_get_stack_frame_at_level(ctx, level); + if (!target) + return JS_ThrowReferenceError(ctx, "no stack frame at level %d", level); + + if (JS_VALUE_GET_TAG(target->cur_func) != JS_TAG_OBJECT) + return JS_ThrowTypeError(ctx, + "stack frame at level %d is not a JS function", + level); + JSObject *p = JS_VALUE_GET_OBJ(target->cur_func); + if (p->class_id != JS_CLASS_BYTECODE_FUNCTION) + return JS_ThrowTypeError(ctx, + "stack frame at level %d is not a JS function", + level); + JSFunctionBytecode *b = p->u.func.function_bytecode; + + /* Pick the deepest lexical scope index so add_closure_variables() walks + through every enclosing block scope, exposing let/const bindings as + well as args, var-declared locals and closure refs. */ + int scope_idx = -1; + int max_level = 0; + for (int i = 0; i < b->var_count; i++) { + JSVarDef *vd = &b->vardefs[b->arg_count + i]; + if (vd->scope_level > max_level) { + max_level = vd->scope_level; + scope_idx = i; + } + } + + JSRuntime *rt = ctx->rt; + JSStackFrame *saved = rt->current_stack_frame; + rt->current_stack_frame = target; + + JSValue ret = JS_EvalInternal(ctx, JS_UNDEFINED, input, input_len, + filename ? filename : "", + 1, JS_EVAL_TYPE_DIRECT, scope_idx); + + rt->current_stack_frame = saved; + return ret; +} + typedef enum JSFreeModuleEnum { JS_FREE_MODULE_ALL, JS_FREE_MODULE_NOT_RESOLVED, @@ -17827,10 +17995,13 @@ static JSValue JS_CallInternal(JSContext *caller_ctx, JSValueConst func_obj, SWITCH(pc) { CASE(OP_debug): + CASE(OP_debugger_stmt): if (unlikely(ctx->debug_trace)) { int col_num = 0; int line_num = -1; uint32_t pc_index = (uint32_t)(pc - b->byte_code_buf - 1); + int flags = (pc[-1] == OP_debugger_stmt) + ? JS_DEBUG_TRACE_DEBUGGER_STMT : 0; line_num = find_line_num(ctx, b, pc_index, &col_num); /* Pass the JSAtom values directly — no heap allocation. @@ -17838,7 +18009,7 @@ static JSValue JS_CallInternal(JSContext *caller_ctx, JSValueConst func_obj, object, which outlives the callback. The embedder must call JS_DupAtom() if it needs to retain them. */ int ret = ctx->debug_trace(ctx, b->filename, b->func_name, - line_num, col_num, + line_num, col_num, flags, ctx->debug_trace_opaque); if (ret != 0 || JS_HasException(ctx)) { @@ -29777,7 +29948,14 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, break; case TOK_DEBUGGER: - /* currently no debugger, so just skip the keyword */ + /* Emit OP_source_loc + OP_debugger_stmt unconditionally so that an + attached debugger can pause on the `debugger` statement even when + the handler was not yet set at parse time. The dedicated opcode + lets the trace handler distinguish a real `debugger;` statement + from a statement-boundary OP_debug via the JS_DEBUG_TRACE_DEBUGGER_STMT + flag. */ + emit_source_loc(s); + dbuf_putc(&s->cur_func->byte_code, OP_debugger_stmt); if (next_token(s)) goto fail; if (js_parse_expect_semi(s)) @@ -34157,7 +34335,7 @@ static bool code_match(CodeContext *s, int pos, ...) line_num = get_u32(tab + pos + 1); col_num = get_u32(tab + pos + 5); pos = pos_next; - } else if (op == OP_debug) { + } else if (op == OP_debug || op == OP_debugger_stmt) { pos = pos_next; } else { break; @@ -34447,6 +34625,7 @@ static int get_label_pos(JSFunctionDef *s, int label) pos += 9; continue; case OP_debug: + case OP_debugger_stmt: pos += 1; continue; case OP_label: @@ -35217,6 +35396,12 @@ static __exception int resolve_labels(JSContext *ctx, JSFunctionDef *s) dbuf_putc(&bc_out, OP_debug); break; + case OP_debugger_stmt: + /* same as OP_debug but carries the `debugger;` flag at runtime */ + add_pc2line_info(s, bc_out.size, line_num, col_num); + dbuf_putc(&bc_out, OP_debugger_stmt); + break; + case OP_label: { label = get_u32(bc_buf + pos + 1); diff --git a/quickjs.h b/quickjs.h index 0bec35988..19e881394 100644 --- a/quickjs.h +++ b/quickjs.h @@ -547,11 +547,15 @@ JS_EXTERN JSValue JS_GetFunctionProto(JSContext *ctx); JSValue when needed. Accepting JSAtom avoids a heap allocation on every instrumented statement when the embedder only needs to compare against a known set of breakpoint locations. */ +/* Flags passed to JSDebugTraceFunc. Use bitwise-AND to test specific bits. */ +#define JS_DEBUG_TRACE_DEBUGGER_STMT (1 << 0) /* triggered by `debugger;` statement */ + typedef int JSDebugTraceFunc(JSContext *ctx, JSAtom filename, JSAtom funcname, int line, int col, + int flags, void *opaque); /* Set (or clear) the debug trace handler on a context. Pass NULL to @@ -567,6 +571,7 @@ typedef struct JSDebugLocalVar { const char *name; JSValue value; bool is_arg; + bool is_closure; /* true if captured from an enclosing scope */ int scope_level; } JSDebugLocalVar; @@ -577,7 +582,10 @@ JS_EXTERN int JS_GetStackDepth(JSContext *ctx); On success, *pvars receives an allocated array of JSDebugLocalVar entries that must be freed with JS_FreeLocalVariables(), and *pcount receives the entry count. If no variables are available, *pvars is set to NULL and - *pcount is set to 0. Returns -1 on exception. */ + *pcount is set to 0. Returns -1 on exception. + + The returned array contains arguments first, then locals, then any closure + variables captured from enclosing scopes (with `is_closure = true`). */ JS_EXTERN int JS_GetLocalVariablesAtLevel(JSContext *ctx, int level, JSDebugLocalVar **pvars, int *pcount); @@ -585,6 +593,27 @@ JS_EXTERN int JS_GetLocalVariablesAtLevel(JSContext *ctx, int level, /* Free local variables array returned by JS_GetLocalVariablesAtLevel */ JS_EXTERN void JS_FreeLocalVariables(JSContext *ctx, JSDebugLocalVar *vars, int count); +/* Set a local or closure variable in a stack frame by name. + Returns 0 on success, -1 if the variable is not found, -2 if it is a const + binding (read-only), or -3 on type/argument errors. */ +JS_EXTERN int JS_SetVariableAtLevel(JSContext *ctx, int level, + const char *name, JSValue value); + +/* Evaluate an expression in the context of the given stack frame. + The expression has access to the frame's local and closure variables. + On success, returns the resulting JSValue. On error, returns + JS_EXCEPTION (the exception is set on `ctx`). + + This is a best-effort implementation: simple identifiers are resolved + against the frame's bindings, and otherwise the expression is evaluated + in a synthetic scope that surfaces the frame variables as globals on a + transient object. Modifications to those bindings are NOT propagated + back to the underlying stack frame -- use JS_SetVariableAtLevel for + that. */ +JS_EXTERN JSValue JS_EvalInStackFrame(JSContext *ctx, int level, + const char *input, size_t input_len, + const char *filename); + /* the following functions are used to select the intrinsic object to save memory */ JS_EXTERN JSContext *JS_NewContextRaw(JSRuntime *rt);