Debugger Events vs. Stack Walking

@rebolbot -- You've suggested that interpreted languages like JavaScript or Python don't implement their debug stack tools with incremental maintenance via events.

But is the event model in these cases complete enough that if the debugger were running from start to finish it could maintain an accurate program stack (one that would mirror the interpreter stack exactly if queried) from the events alone without reflecting the stack?

Do things like RETURN or THROW or other disruptions have events in the model?

Yes—in most interpreted-language debugging APIs the event model is intentionally designed to be stack-complete, meaning a debugger could maintain an accurate mirror of the interpreter’s call stack purely from events if it started from the beginning of execution.

However, there are important subtleties and exceptions, especially around exceptions, generators/coroutines, and async scheduling.

I'll break this down by runtime.


1. The General Design Goal: Stack-Reconstructible Event Streams

Most debugging APIs follow this conceptual rule:

The debugger should be able to reconstruct execution state from events without directly inspecting runtime internals.

So the event stream typically includes:

Event Stack Effect
call push frame
return pop frame
exception/throw pop until handler
script start initial frame
script end empty stack

This makes the event stream logically equivalent to stack operations.

A debugger could maintain:

debugger_stack
    push on CALL
    pop on RETURN

This mirrors the interpreter stack.

But there are caveats.


2. CPython: The Model Is Almost Stack-Complete

CPython tracing events:

call
line
return
exception
opcode (optional)

These are generated by the interpreter’s frame evaluation loop.

Stack reconstruction

A debugger could maintain:

stack = []

on call:
    stack.push(frame)

on return:
    stack.pop()

This works for normal execution.


Exception Semantics

CPython produces exception events at each stack level while the exception propagates.

Example:

A()
  B()
    C()  → raises

Event stream roughly:

call A
call B
call C
exception in C
return C
exception in B
return B
exception in A
return A

So the debugger can observe:

  • exception propagation
  • frame unwinding

Meaning stack updates remain reconstructible.


Subtle Case: Exceptions Caught in Same Frame

If a frame catches the exception:

try:
    f()
except:
    ...

You will see:

call f
exception f
return f

But the outer frame does not unwind.

This still preserves correctness.


3. Why Most Python Debuggers Don't Maintain Their Own Stack

Even though they could, they don't.

Reasons:

1. Frame objects already exist

The interpreter maintains:

frame → f_back → f_back

Walking this chain is cheap.

2. Trace callbacks receive the frame

Every trace event includes:

trace(frame, event, arg)

So you already have access to the stack.

3. Resuming mid-program

If a debugger attaches after execution begins, it needs to read the current stack.

Maintaining a parallel stack becomes unnecessary.


4. Stackless Python: Still Stack-Reconstructible

Stackless introduces tasklets, but the same tracing events exist.

The difference is scheduling.

Event stream might look like:

tasklet1: call A
tasklet1: call B
switch
tasklet2: call X
switch
tasklet1: return B

To reconstruct the stack you must maintain per-tasklet stacks.

Conceptually:

stack[tasklet_id]

So the event stream is still complete, but you must track which tasklet emitted the event.


5. JavaScript Engines: Slightly Different

JavaScript engines emit debugging events such as:

scriptParsed
breakpointHit
exceptionThrown
callFrame

However, historically they do NOT always emit explicit call/return events.

Instead they provide the stack when execution pauses.

Example (V8):

Debugger.paused
    callFrames: [...]

So the debugger receives a snapshot stack, not incremental updates.

This is partly because:

  • JIT compilation complicates frame tracking
  • functions may inline into others

So engines often reconstruct frames when paused.


6. JIT Complication: Inlined Functions

In modern JS engines:

function A() {
  B()
}

The JIT may inline B into A.

So the physical stack becomes:

A

But the debugger expects:

A
B

So the engine must materialize a virtual stack.

This means:

  • stack cannot reliably be maintained by debugger events alone
  • the engine reconstructs the logical stack when paused

7. Asynchronous Execution

Another challenge is async boundaries.

Example JS:

async function A() {
  await B()
}

The logical stack:

A → B

But physically:

A returns
event loop
B runs

Engines build async stack traces by tracking promise continuations.

These are not reconstructible purely from call/return events.

They require runtime metadata.


8. Event Completeness Comparison

Runtime Stack reconstructible from events? Reason
CPython Yes explicit call/return/exception
Stackless Yes (per tasklet) same events
PyPy Mostly similar tracing model
V8 Not always JIT + inlining
SpiderMonkey Not always optimized frames
JVM Yes (mostly) method entry/exit events

9. Rare Edge Case: Generator Suspension (Python)

Generators complicate stack semantics.

Example:

def g():
    yield

Execution:

call g
return g   ← generator created
call generator
yield
return generator

The generator frame remains alive but suspended.

So a debugger maintaining its own stack would see:

g frame popped

But the generator frame still exists.

So the interpreter stack and logical coroutine stack diverge.


10. The Key Takeaway

In languages like Python:

The debugger event model is intentionally rich enough that a debugger could reconstruct the interpreter stack exactly from events.

But in practice debuggers don't do this because:

  • the runtime already maintains frame objects
  • stack inspection is cheap
  • attaching mid-execution would break the model

In modern JIT runtimes (JavaScript, JVM with inlining):

The debugger cannot rely on call/return events alone, because the physical stack diverges from the logical stack.

So engines reconstruct the logical stack when paused.


:white_check_mark: Short Answer

Yes—for interpreters like CPython the event model is complete enough that a debugger running from program start could maintain an exact mirror of the interpreter call stack using call, return, and exception events alone.

However:

  • most debuggers don't do this because frame objects already exist
  • generators/coroutines complicate the model
  • JIT runtimes (like JavaScript engines) often cannot guarantee this due to inlining and async execution

If you're interested, there's an even deeper topic here:

Why CPython’s tracing API is considered fundamentally slow and why modern Python debuggers are moving away from it (PEP 669).

That design change reveals a lot about how interpreter-level debugging really works.