diff --git a/api-test.c b/api-test.c index d17c49acd..88fdf63b1 100644 --- a/api-test.c +++ b/api-test.c @@ -962,6 +962,130 @@ 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) +{ + 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 = JS_GetLocalVariablesAtLevel(ctx, 0, &count); + 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); + + /* no handler set: eval should work and call_count stays 0 */ + 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); + } + + /* set handler: callback fires for each statement */ + JS_SetDebugTraceHandler(ctx, debug_trace_cb); + 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, "")); + } + + /* stack depth inside a nested call */ + 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); + /* the deepest invocation should have a stack depth > 1 */ + /* (just verify we got a sane value; exact depth depends on internals) */ + assert(trace_state.stack_depth >= 1); + } + + /* local variables are visible inside the callback */ + 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); + /* inside f() we should see locals (a, b, c) at some point */ + assert(trace_state.max_local_count >= 2); + } + + /* returning non-zero aborts execution */ + memset(&trace_state, 0, sizeof(trace_state)); + trace_state.abort_at = 1; /* abort on first callback */ + { + 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); + } + + /* clear handler: callbacks no longer fire */ + JS_SetDebugTraceHandler(ctx, 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 = JS_NewRuntime(); @@ -1037,6 +1161,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 909fd718f..76265d5a3 100644 --- a/quickjs-opcode.h +++ b/quickjs-opcode.h @@ -364,6 +364,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) /* debugger trace point */ + #undef DEF #undef def #endif /* DEF */ diff --git a/quickjs.c b/quickjs.c index 535267456..94b5395f3 100644 --- a/quickjs.c +++ b/quickjs.c @@ -536,6 +536,8 @@ 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; }; typedef union JSFloat64Union { @@ -2560,6 +2562,144 @@ JSValue JS_GetFunctionProto(JSContext *ctx) return js_dup(ctx->function_proto); } +void JS_SetDebugTraceHandler(JSContext *ctx, JSDebugTraceFunc *cb) +{ + ctx->debug_trace = cb; +} + +/* Forward declaration: defined later in this file */ +static const char *JS_AtomGetStr(JSContext *ctx, char *buf, int buf_size, JSAtom atom); + +/* Debug API: Get stack frame at specific level */ +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; +} + +/* Get the call stack depth */ +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; +} + +/* Get local variables at a specific stack level (0 = current frame, 1 = caller, etc.) */ +JSDebugLocalVar *JS_GetLocalVariablesAtLevel(JSContext *ctx, int level, int *pcount) +{ + if (pcount) + *pcount = 0; + + JSStackFrame *sf = js_get_stack_frame_at_level(ctx, level); + if (sf == NULL) + return NULL; + + JSValue func = sf->cur_func; + if (JS_VALUE_GET_TAG(func) != JS_TAG_OBJECT) + return NULL; + + JSObject *p = JS_VALUE_GET_OBJ(func); + if (p->class_id != JS_CLASS_BYTECODE_FUNCTION) + return NULL; + + JSFunctionBytecode *b = p->u.func.function_bytecode; + int total_vars = b->arg_count + b->var_count; + + if (total_vars == 0) + return NULL; + + JSDebugLocalVar *vars = js_malloc(ctx, sizeof(JSDebugLocalVar) * total_vars); + if (!vars) + return NULL; + + int idx = 0; + + /* Helper macro to capture a single variable; on JS_AtomToCString failure + (typically OOM), unwind everything and return NULL with *pcount = 0. */ +#define APPEND_VAR(vd_, value_, is_arg_) \ + do { \ + JSAtom name_ = (vd_)->var_name; \ + const char *name_str_; \ + /* Skip compiler-generated internal names like , */ \ + 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_; \ + /* JS_UNINITIALIZED is an internal sentinel (let/const TDZ); */ \ + /* expose it as undefined 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) + + /* First, get arguments */ + for (int i = 0; i < b->arg_count; i++) { + JSVarDef *vd = &b->vardefs[i]; + APPEND_VAR(vd, sf->arg_buf[i], 1); + } + + /* Then, get local variables */ + for (int i = 0; i < b->var_count; i++) { + JSVarDef *vd = &b->vardefs[b->arg_count + i]; + APPEND_VAR(vd, sf->var_buf[i], 0); + } + +#undef APPEND_VAR + + if (pcount) + *pcount = idx; + return vars; + +fail: + /* JS_AtomToCString failed (OOM). Free what we have, clear the pending + exception so it does not leak to the next API call, and return NULL. */ + for (int i = 0; i < idx; i++) { + JS_FreeCString(ctx, vars[i].name); + JS_FreeValue(ctx, vars[i].value); + } + js_free(ctx, vars); + JS_FreeValue(ctx, JS_GetException(ctx)); + if (pcount) + *pcount = 0; + return NULL; +} + +/* Free local variables array */ +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, @@ -17580,6 +17720,43 @@ 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); + 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; @@ -22453,6 +22630,7 @@ static __exception int next_token(JSParseState *s) if (JS_VALUE_IS_NAN(ret) || lre_js_is_ident_next(utf8_decode(p, &p1))) { JS_FreeValue(s->ctx, ret); + s->col_num = max_int(1, s->mark - s->eol); js_parse_error(s, "invalid number literal"); goto fail; } @@ -23167,6 +23345,18 @@ static void emit_source_loc(JSParseState *s) dbuf_put_u32(bc, s->token.col_num); } +/* Emit an OP_source_loc + OP_debug pair at a statement boundary, but only + when a debug trace handler is currently registered on the context. When + no handler is set this is a no-op, so the produced bytecode is identical + to upstream and there is zero runtime overhead. */ +static void emit_source_loc_debug(JSParseState *s) +{ + if (unlikely(s->ctx->debug_trace)) { + emit_source_loc(s); + dbuf_putc(&s->cur_func->byte_code, OP_debug); + } +} + static void emit_op(JSParseState *s, uint8_t val) { JSFunctionDef *fd = s->cur_func; @@ -28282,6 +28472,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; @@ -28309,6 +28500,7 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, js_parse_error(s, "line terminator not allowed after throw"); goto fail; } + emit_source_loc_debug(s); emit_source_loc(s); if (js_parse_expr(s)) goto fail; @@ -28325,6 +28517,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)) @@ -28335,6 +28528,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(){}` */ @@ -28443,6 +28637,7 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, int tok, bits; bool is_async; + emit_source_loc_debug(s); if (next_token(s)) goto fail; @@ -28609,6 +28804,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; @@ -28956,6 +29152,7 @@ static __exception int js_parse_statement_or_decl(JSParseState *s, default: hasexpr: + emit_source_loc_debug(s); emit_source_loc(s); if (js_parse_expr(s)) goto fail; @@ -33315,6 +33512,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; } @@ -33602,6 +33801,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; @@ -34307,6 +34509,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 f87b06d26..58bbf123a 100644 --- a/quickjs.h +++ b/quickjs.h @@ -543,6 +543,57 @@ 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); + +/* 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); + +/* Debug API: Get local variables in stack frames */ +typedef struct JSDebugLocalVar { + const char *name; /* variable name */ + JSValue value; /* variable value */ + int is_arg; /* 1 if argument, 0 if local variable */ + int scope_level; /* scope level of the variable */ +} 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.) + *pcount: output, number of variables returned + Returns allocated array of JSDebugLocalVar (must be freed with JS_FreeLocalVariables), + or NULL on error. */ +JS_EXTERN JSDebugLocalVar *JS_GetLocalVariablesAtLevel(JSContext *ctx, int level, 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);