[Enhancement] Python (Pyodide) playground: Monaco debugger toolbar — sys.settrace, stepping, vars, Monaco bridge #34

Closed
opened 2026-04-28 23:15:58 +00:00 by spikerj · 2 comments
Owner

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.
## 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.
Author
Owner

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

  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.

## 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.
Author
Owner

Resolved. Landed in spikersoft-angular PR #60 (merged to master, commit c1c8082): Monaco step debugger for the Python (Pyodide) playground (sys.settrace-based stepping, variables, Monaco bridge). Closing.

Resolved. Landed in spikersoft-angular PR #60 (merged to `master`, commit `c1c8082`): Monaco step debugger for the Python (Pyodide) playground (sys.settrace-based stepping, variables, Monaco bridge). Closing.
Sign in to join this conversation.