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.
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.