diff --git a/api-test.c b/api-test.c index 7aa7dc8a6..403cfec49 100644 --- a/api-test.c +++ b/api-test.c @@ -1013,6 +1013,142 @@ 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, + JSAtom filename, + JSAtom funcname, + int line, + int col, + void *opaque) +{ + trace_state.call_count++; + trace_state.last_line = line; + trace_state.last_col = col; + /* 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; + 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 +1225,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..008856bc7 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,32 @@ 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); + + /* 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); + + 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 +23575,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 +28953,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 +28982,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 +29006,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 +29029,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 +29040,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 +29151,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 +29387,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 +29739,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 +29793,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 +34157,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 +34446,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 +35210,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..0bec35988 100644 --- a/quickjs.h +++ b/quickjs.h @@ -523,6 +523,68 @@ 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. + + 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. + + '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, + JSAtom filename, + JSAtom 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);