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);