Add a Monaco debugger toolbar for the Python (Pyodide) playground. Python is especially well-suited vs C# WASM PDB path: interpreter semantics + sys.settrace hooks provide near-native line stepping inside Pyodide without rewriting user code semantics.
Core mechanism — sys.settrace
Install a Python trace function firing on line, call, return (see CPython tracer contract).
Respect step over / into / out logic by consulting frame stack-depth and event type.
Continue clears pause state until next breakpoint/trace hit.
Async bridge — don’t freeze the UI
⚠️Avoid busy-waits (while paused: pass) — they starve the JS event loop.
Use yield-to-JS patterns: cooperative pause (e.g. async boundary, asyncio/microtask hooks, explicit await on a JS-provided resolver, or raise/suspend pattern) so onNextStep() (or equivalent) resumes from TS without blocking the worker forever.
Python is interpreted and introspectable at runtime (settrace). Closer to “real debugger” ergonomics inside Pyodide than IL/PDB gymnastics—deliberately different playbook than the C# ticket.
Acceptance criteria
Tracer-backed stepping usable from Monaco toolbar (no browser freeze) in MVP flows.
Variables + line sync reliably for lesson-sized scripts.
Interrupt/timeout path for non-terminating user code documented + tested.
Related
#24 Monaco migration umbrella; Python-specific debugger work stacks on Pyodide execution path.
## Summary
Add a **Monaco debugger toolbar** for the **Python (Pyodide) playground**. Python is especially well-suited vs C# **WASM PDB** path: interpreter semantics + **`sys.settrace`** hooks provide **near-native line stepping** inside **Pyodide** without rewriting user code semantics.
## Core mechanism — `sys.settrace`
- Install a Python **trace function** firing on **`line`**, **`call`**, **`return`** (see CPython tracer contract).
- On **`line`** events:
- Report **`frame.f_lineno`** → Monaco decorations (paused line).
- Surface **`frame.f_locals`** / **`f_globals`** (filtered) → variables panel.
- Respect **step over / into / out** logic by consulting frame stack-depth and event type.
- **Continue** clears pause state until next breakpoint/trace hit.
## Async bridge — don’t freeze the UI
- ⚠️ **Avoid busy-waits** (`while paused: pass`) — they starve the JS event loop.
- Use **yield-to-JS patterns**: cooperative pause (e.g. async boundary, **`asyncio`/microtask hooks**, explicit await on a JS-provided resolver, or **raise**/suspend pattern) so **`onNextStep()`** (or equivalent) resumes from TS without blocking the worker forever.
- Document chosen pattern Pyodide + browser constraints.
## Safety — infinite loops / runaway traces
- **Timeouts** / interrupt strategy for **`while True`**, deep recursion.
- Cap **trace granularity** perf for curriculum-scale code (acceptable slowdown for teaching).
## Feature targets
- **Breakpoints**: map gutter ↔ lineno (optionally bytecode line table if needed).
- **Step over / into / out** using stack depth + tracer events.
- **Locals / globals** pane (whitelist sensitive builtins).
- **Monaco** current-line highlight + gutter markers.
## Why this path vs C#
- Python is interpreted and introspectable at runtime (`settrace`). Closer to “real debugger” ergonomics inside Pyodide than IL/PDB gymnastics—**deliberately different playbook** than the C# ticket.
## Acceptance criteria
- [ ] Tracer-backed **stepping** usable from Monaco toolbar (**no browser freeze**) in MVP flows.
- [ ] **Variables + line** sync reliably for lesson-sized scripts.
- [ ] **Interrupt/timeout** path for non-terminating user code documented + tested.
## Related
- `#24` Monaco migration umbrella; Python-specific debugger work stacks on Pyodide execution path.
Built a throwaway spike to de-risk the core mechanism before any production wiring. It runs the vendored Pyodide version (0.28.x) under Node worker_threads — the threading / SharedArrayBuffer / Atomics mechanics are identical to a browser Web Worker, so Node just stands in for the browser here.
What it proves
A synchronoussys.settrace trace function can pause line-by-line and be driven from another thread without busy-waiting and without freezing the UI:
Pause-bridge: the Python tracer's line handler calls a JS hook that postMessages the pause snapshot (line, depth, locals) to the main thread, then parks the worker on Atomics.wait on a control SharedArrayBuffer. The main thread is never blocked; it replies by writing a command int + Atomics.notify. This is the natural fit because settrace is synchronous and can't await (ruling out the async-resolver idea).
Stepping via call-stack depth (frame.f_back), mirroring the existing JS debugger's predicates: Step Into (any next line), Step Over (depth <= refDepth), Step Out (depth < refDepth), Continue (breakpoints only).
Breakpoints + live variables: filtered/capped frame.f_locals serialized to JSON on each pause.
Interrupt / runaway code:pyodide.setInterruptBuffer(...) raises KeyboardInterrupt to kill a while True: running in Continue mode — satisfies the ticket's timeout/interrupt requirement.
Sample output
=== Scenario 1: step into / over / out ===
[pause #3] line 7 (<module>, depth 2) {x=1, y=2} -> STEP_INTO
[pause #4] line 2 (add, depth 3) {a=1, b=2} -> STEP_OUT
[pause #5] line 8 (<module>, depth 2) {x=1, y=2, z=3} -> CONTINUE
=== Scenario 2: breakpoint on line 11 (loop body) ===
[pause #1] line 11 {total=0, i=0} ... [pause #3] line 11 {total=1, i=2}
=== Scenario 3: interrupt while True ===
[main] firing interrupt buffer (SIGINT) ... KeyboardInterrupt
Carry-over notes for the real implementation
Cross-origin isolation is required for SharedArrayBuffer. Already holds in prod (backend sets COOP: same-origin + COEP: require-corp; the Alpine emulator already depends on SharedArrayBuffer). The real service should still feature-detect crossOriginIsolated and disable the debugger toolbar (with a tooltip) if it's ever false.
Reuse the existing classic worker (spikersoft-pyodide-worker.js): add a kind: "debug" RPC alongside init/lesson/freePlay; don't touch the lesson-harness contract.
Filter tracer events to user frames via co_filename == "<playground>" (matching the existing free-play wrapper). Note the top-level module frame sits at depth 2 under the exec wrapper.
Step Back is out of scope for v1 (no cheap settrace snapshot/restore, unlike js-interpreter's serialize.js).
Tracing roughly halves throughput — only enable it during an active debug session, not for the normal Run path.
Planned build (mirrors the JS debugger package)
New libraries/platform/python-step-debugger service (same DebuggerState/DebuggerCapabilities/signals shape as js-step-debugger), reusing the engine-agnostic BreakpointStore.
kind: "debug" RPC in the Pyodide worker + an event channel in PyodideWorkerClient.
Copy the toolbar + Monaco bridge (python-debugger-monaco-bridge.ts), generalize the .js-debug-* CSS, wire into the python-runner shell via the existing [debug-toolbar] / [debug-side-panel] slots.
Python variables panel + streamed stdout.
Interrupt/timeout + tests + docs.
Proceeding with step 1 next.
## Feasibility spike: `sys.settrace` + `Atomics.wait` pause-bridge — proven ✅
Built a throwaway spike to de-risk the core mechanism before any production wiring. It runs the vendored Pyodide version (0.28.x) under Node `worker_threads` — the threading / `SharedArrayBuffer` / `Atomics` mechanics are identical to a browser Web Worker, so Node just stands in for the browser here.
### What it proves
A **synchronous** `sys.settrace` trace function can pause line-by-line and be driven from another thread **without busy-waiting and without freezing the UI**:
- **Pause-bridge:** the Python tracer's `line` handler calls a JS hook that `postMessage`s the pause snapshot (line, depth, locals) to the main thread, then **parks the worker on `Atomics.wait`** on a control `SharedArrayBuffer`. The main thread is never blocked; it replies by writing a command int + `Atomics.notify`. This is the natural fit because `settrace` is synchronous and can't `await` (ruling out the async-resolver idea).
- **Stepping** via call-stack depth (`frame.f_back`), mirroring the existing JS debugger's predicates: Step Into (any next line), Step Over (`depth <= refDepth`), Step Out (`depth < refDepth`), Continue (breakpoints only).
- **Breakpoints + live variables:** filtered/capped `frame.f_locals` serialized to JSON on each pause.
- **Interrupt / runaway code:** `pyodide.setInterruptBuffer(...)` raises `KeyboardInterrupt` to kill a `while True:` running in Continue mode — satisfies the ticket's timeout/interrupt requirement.
### Sample output
```
=== Scenario 1: step into / over / out ===
[pause #3] line 7 (<module>, depth 2) {x=1, y=2} -> STEP_INTO
[pause #4] line 2 (add, depth 3) {a=1, b=2} -> STEP_OUT
[pause #5] line 8 (<module>, depth 2) {x=1, y=2, z=3} -> CONTINUE
=== Scenario 2: breakpoint on line 11 (loop body) ===
[pause #1] line 11 {total=0, i=0} ... [pause #3] line 11 {total=1, i=2}
=== Scenario 3: interrupt while True ===
[main] firing interrupt buffer (SIGINT) ... KeyboardInterrupt
```
### Carry-over notes for the real implementation
- **Cross-origin isolation is required** for `SharedArrayBuffer`. Already holds in prod (backend sets `COOP: same-origin` + `COEP: require-corp`; the Alpine emulator already depends on `SharedArrayBuffer`). The real service should still feature-detect `crossOriginIsolated` and disable the debugger toolbar (with a tooltip) if it's ever false.
- Reuse the existing classic worker (`spikersoft-pyodide-worker.js`): add a `kind: "debug"` RPC alongside `init`/`lesson`/`freePlay`; **don't** touch the lesson-harness contract.
- Filter tracer events to user frames via `co_filename == "<playground>"` (matching the existing free-play wrapper). Note the top-level module frame sits at depth 2 under the `exec` wrapper.
- **Step Back is out of scope for v1** (no cheap `settrace` snapshot/restore, unlike js-interpreter's `serialize.js`).
- Tracing roughly halves throughput — only enable it during an active debug session, not for the normal Run path.
### Planned build (mirrors the JS debugger package)
1. New `libraries/platform/python-step-debugger` service (same `DebuggerState`/`DebuggerCapabilities`/signals shape as `js-step-debugger`), reusing the engine-agnostic `BreakpointStore`.
2. `kind: "debug"` RPC in the Pyodide worker + an event channel in `PyodideWorkerClient`.
3. Copy the toolbar + Monaco bridge (`python-debugger-monaco-bridge.ts`), generalize the `.js-debug-*` CSS, wire into the `python-runner` shell via the existing `[debug-toolbar]` / `[debug-side-panel]` slots.
4. Python variables panel + streamed stdout.
5. Interrupt/timeout + tests + docs.
Proceeding with step 1 next.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Add a Monaco debugger toolbar for the Python (Pyodide) playground. Python is especially well-suited vs C# WASM PDB path: interpreter semantics +
sys.settracehooks provide near-native line stepping inside Pyodide without rewriting user code semantics.Core mechanism —
sys.settraceline,call,return(see CPython tracer contract).lineevents:frame.f_lineno→ Monaco decorations (paused line).frame.f_locals/f_globals(filtered) → variables panel.Async bridge — don’t freeze the UI
while paused: pass) — they starve the JS event loop.asyncio/microtask hooks, explicit await on a JS-provided resolver, or raise/suspend pattern) soonNextStep()(or equivalent) resumes from TS without blocking the worker forever.Safety — infinite loops / runaway traces
while True, deep recursion.Feature targets
Why this path vs C#
settrace). Closer to “real debugger” ergonomics inside Pyodide than IL/PDB gymnastics—deliberately different playbook than the C# ticket.Acceptance criteria
Related
#24Monaco migration umbrella; Python-specific debugger work stacks on Pyodide execution path.Feasibility spike:
sys.settrace+Atomics.waitpause-bridge — proven ✅Built a throwaway spike to de-risk the core mechanism before any production wiring. It runs the vendored Pyodide version (0.28.x) under Node
worker_threads— the threading /SharedArrayBuffer/Atomicsmechanics are identical to a browser Web Worker, so Node just stands in for the browser here.What it proves
A synchronous
sys.settracetrace function can pause line-by-line and be driven from another thread without busy-waiting and without freezing the UI:linehandler calls a JS hook thatpostMessages the pause snapshot (line, depth, locals) to the main thread, then parks the worker onAtomics.waiton a controlSharedArrayBuffer. The main thread is never blocked; it replies by writing a command int +Atomics.notify. This is the natural fit becausesettraceis synchronous and can'tawait(ruling out the async-resolver idea).frame.f_back), mirroring the existing JS debugger's predicates: Step Into (any next line), Step Over (depth <= refDepth), Step Out (depth < refDepth), Continue (breakpoints only).frame.f_localsserialized to JSON on each pause.pyodide.setInterruptBuffer(...)raisesKeyboardInterruptto kill awhile True:running in Continue mode — satisfies the ticket's timeout/interrupt requirement.Sample output
Carry-over notes for the real implementation
SharedArrayBuffer. Already holds in prod (backend setsCOOP: same-origin+COEP: require-corp; the Alpine emulator already depends onSharedArrayBuffer). The real service should still feature-detectcrossOriginIsolatedand disable the debugger toolbar (with a tooltip) if it's ever false.spikersoft-pyodide-worker.js): add akind: "debug"RPC alongsideinit/lesson/freePlay; don't touch the lesson-harness contract.co_filename == "<playground>"(matching the existing free-play wrapper). Note the top-level module frame sits at depth 2 under theexecwrapper.settracesnapshot/restore, unlike js-interpreter'sserialize.js).Planned build (mirrors the JS debugger package)
libraries/platform/python-step-debuggerservice (sameDebuggerState/DebuggerCapabilities/signals shape asjs-step-debugger), reusing the engine-agnosticBreakpointStore.kind: "debug"RPC in the Pyodide worker + an event channel inPyodideWorkerClient.python-debugger-monaco-bridge.ts), generalize the.js-debug-*CSS, wire into thepython-runnershell via the existing[debug-toolbar]/[debug-side-panel]slots.Proceeding with step 1 next.
Resolved. Landed in spikersoft-angular PR #60 (merged to
master, commitc1c8082): Monaco step debugger for the Python (Pyodide) playground (sys.settrace-based stepping, variables, Monaco bridge). Closing.