sys.monitoring Integration Plan
A phased plan for integrating Python 3.12+'s sys.monitoring API into
Dapper as a high-performance alternative to the existing sys.settrace /
frame-evaluation tracing backend.
Reference: https://docs.python.org/3/library/sys.monitoring.html
Motivation
sys.monitoring (PEP 669, Python 3.12+) is purpose-built to replace
sys.settrace for debuggers, profilers, and coverage tools. Key advantages:
- Near-zero overhead for unmonitored code — events are disabled per code-object; non-breakpoint frames run at full speed.
- No per-frame trace-function dispatching — eliminates the
SelectiveTraceDispatcherentirely; the VM itself skips unsolicited callbacks. DISABLEreturn value — a callback can returnsys.monitoring.DISABLEto surgically turn off monitoring at a single bytecode offset, making non-breakpoint lines a one-time cost.- First-class tool identity —
sys.monitoring.DEBUGGER_ID(0) is reserved for debuggers; no collision with profilers/coverage.
The existing _frame_eval subsystem with bdb/sys.settrace remains the
fallback for Python 3.9–3.11.
Architecture
Event Mapping
sys.monitoring event |
Dapper use case | Replaces |
|---|---|---|
LINE |
Line breakpoints, stepping | sys.settrace dispatch_line → user_line |
CALL |
Function breakpoints | sys.settrace dispatch_call → user_call |
PY_START / PY_RETURN |
Code-object discovery, step-over boundary, call-stack tracking | sys.settrace dispatch_call/return |
PY_YIELD / PY_RESUME |
Async/generator stepping | Not currently handled cleanly |
RAISE / RERAISE |
Exception breakpoints | sys.settrace dispatch_exception → user_exception |
EXCEPTION_HANDLED |
"User-unhandled" exception filter | Not currently possible cleanly |
INSTRUCTION |
Instruction-level stepping, read watchpoints | frame.f_trace_opcodes = True |
BRANCH_LEFT / BRANCH_RIGHT |
Coverage-aware breakpoints (feature idea §8) | Not available today |
set_local_events() |
Per-file breakpoint activation | SelectiveTraceDispatcher + FrameTraceAnalyzer |
DISABLE return |
Per-offset event suppression | BytecodeModifier (breakpoint bytecode injection) |
Checklist
Phase 1 — TracingBackend abstraction & backend selection
Introduce the backend interface without changing any runtime behaviour.
All existing code continues to work behind SettraceBackend.
- [x] 1.1 Define
TracingBackendprotocol - Created
dapper/_frame_eval/tracing_backend.py. - Define abstract methods:
install(debugger) → Noneshutdown() → Noneupdate_breakpoints(file: str, lines: set[int]) → Noneset_stepping(mode: StepMode) → Noneset_exception_breakpoints(filters: list[str]) → Noneget_statistics() → dict[str, Any]
-
Document thread-safety expectations in docstrings.
-
[x] 1.2 Add
TracingBackendKindenum to config - Edited
dapper/_frame_eval/config.py. - Add
TracingBackendKindenum with valuesAUTO,SETTRACE,SYS_MONITORING. - Add
tracing_backend: TracingBackendKind = TracingBackendKind.AUTOfield toFrameEvalConfig. -
Update
to_dict()/from_dict()to serialise the new field. -
[x] 1.3 Wrap existing code in
SettraceBackend - Created
dapper/_frame_eval/settrace_backend.py. - Implement
TracingBackendby delegating toSelectiveTraceDispatcher,DebuggerFrameEvalBridge, andBytecodeModifier. -
Existing functionality must be byte-for-byte identical; this is a pure refactoring wrapper.
-
[x] 1.4 Wire backend selection into
FrameEvalManager - Edited
dapper/_frame_eval/frame_eval_main.py. setup()readsconfig.tracing_backend:AUTO→ pickSYS_MONITORINGon 3.12+, elseSETTRACE.SETTRACE/SYS_MONITORING→ use explicitly.
-
shutdown()delegates to the active backend'sshutdown(). -
[x] 1.5 Update compatibility policy
- Edited
dapper/_frame_eval/compatibility_policy.py. - Raise
max_pythonceiling to(3, 14)(or remove it). -
Add
supports_sys_monitoringproperty:Truewhensys.version_info >= (3, 12). -
[x] 1.6 Unit tests for Phase 1
- Created
tests/unit/test_tracing_backend_selection.py(29 tests, all passing).
Phase 2 — SysMonitoringBackend core (line & call events)
A working debugger backend powered entirely by sys.monitoring that
handles breakpoints and stepping.
- [x] 2.1 Create
SysMonitoringBackendclass - Created
dapper/_frame_eval/monitoring_backend.py. - On
install():sys.monitoring.use_tool_id(DEBUGGER_ID, "dapper").- Register callbacks for
LINE,CALL,PY_START,PY_RETURN. - Store reference to the debugger instance.
- Raises
RuntimeErrorifDEBUGGER_IDslot is already held.
-
On
shutdown():sys.monitoring.set_events(DEBUGGER_ID, NO_EVENTS).- Unregister all callbacks.
sys.monitoring.free_tool_id(DEBUGGER_ID).
-
[x] 2.2
LINEcallback — breakpoint hits - Callback signature:
(code: CodeType, line_number: int) → object. - Look up
code.co_filenamein breakpoint registry. - If
line_numbernot in breakpoint set and not stepping → returnDISABLE. - If conditional breakpoint → evaluate via
ConditionEvaluator; if falsy → returnNone(notDISABLE, so condition can be re-evaluated). - Otherwise, obtain frame via
sys._getframe(1)and calldebugger.user_line(frame). -
Stepping path: any
LINEevent firesuser_linewhen_step_mode ≠ CONTINUE. -
[x] 2.3 Breakpoint management via
set_local_events() - Maintains
_code_registry: dict[str, set[CodeType]]and_breakpoints: dict[str, frozenset[int]]. update_breakpoints(file, lines):- For each code object in
code_registry[file]: - If
linesis non-empty →sys.monitoring.set_local_events(DEBUGGER_ID, code, events.LINE). - If
linesis empty →sys.monitoring.set_local_events(DEBUGGER_ID, code, events.NO_EVENTS). - Calls
sys.monitoring.restart_events()to re-enable any previouslyDISABLEd offsets.
- For each code object in
-
set_conditions(filepath, line, expression)wires per-line conditions. -
[x] 2.4 Code-object registry via
PY_START PY_STARTcallback:(code, instruction_offset) → object.- Register
codeincode_registry[code.co_filename]. - If that file has active breakpoints, immediately call
set_local_events(DEBUGGER_ID, code, events.LINE)so new functions are monitored as they are first entered. -
Always returns
DISABLE(code object now known; no need for futurePY_STARTcalls on this(code, offset)pair). -
[x] 2.5 Stepping support
set_stepping(STEP_IN):sys.monitoring.set_events(DEBUGGER_ID, events.LINE | events.PY_START | events.PY_RETURN)globally.
set_stepping(STEP_OVER):- Enable
LINEonly on the current code object viaset_local_events(set bycapture_step_context). - Enable
PY_RETURNglobally so we detect exiting the current frame.
- Enable
set_stepping(STEP_OUT):- Enable
PY_RETURNglobally; disableLINEon current code object.
- Enable
set_stepping(CONTINUE):sys.monitoring.set_events(DEBUGGER_ID, events.PY_START).- Re-enable per-code-object
LINEevents only for files with active breakpoints.
capture_step_context(code)records the code object used forSTEP_OVER/STEP_OUTlocal event control.-
_on_py_returntransitionsSTEP_OVER/STEP_OUT→STEP_INwhen the monitored frame exits. -
[x] 2.6
CALLcallback for function breakpoints - On
CALLevent, match the callable's__qualname__/__name__against the function-breakpoint set. - If match → call
debugger.user_call(frame, arg). - If no match → return
DISABLE. -
update_function_breakpoints(names)manages the set and adds / removes theCALLglobal event flag accordingly. -
[x] 2.7 Bridge to
DebuggerBDB - Added
integrate_with_backend(backend, debugger_instance)todapper/_frame_eval/debugger_integration.py. - If
SysMonitoringBackend: callsbackend.install(debugger_instance)directly — nouser_linewrapping, nosys.settrace. -
If
SettraceBackend(or any other backend): delegates to the existingintegrate_debugger_bdb()path unchanged. -
[x] 2.8 Integration tests for Phase 2
- Created
tests/unit/test_sys_monitoring_backend.py(55 tests, all passing). - Covers: instantiation, install/shutdown lifecycle, DEBUGGER_ID conflict,
code-object registry, breakpoint management,
LINEcallback (DISABLE / hit / conditional), stepping (all four modes,PY_RETURNtransitions),CALLcallback,integrate_with_backendrouting, statistics shape, and concurrent thread safety.
Phase 3 — Exception breakpoints & advanced events
- [ ] 3.1 Exception breakpoints
- Register
RAISEcallback. - On
RAISE: inspect exception type against filter list fromExceptionHandler. - If match → call
debugger.user_exception(frame, exc_info). -
If no match → return
DISABLE. -
[ ] 3.2 "User-unhandled" exceptions via
EXCEPTION_HANDLED - Register
EXCEPTION_HANDLEDcallback. - Track whether a
RAISEwas followed byEXCEPTION_HANDLEDwithin user code (usingjust_my_code.is_user_frame()). -
If the exception propagates out of user code without being handled → break.
-
[ ] 3.3
RERAISEsupport - Register
RERAISEcallback forfinally/exceptre-raise detection. -
Ensure exception info is correctly updated when re-raised.
-
[ ] 3.4 Instruction-level stepping
- Register
INSTRUCTIONevent only whenStepGranularity.INSTRUCTIONis active. - Replaces
frame.f_trace_opcodes = True. - On
INSTRUCTIONcallback → calldebugger.user_opcode(frame). -
Unregister when stepping mode changes back to
LINE. -
[ ] 3.5 Generator/coroutine support
- Handle
PY_YIELD/PY_RESUMEevents to maintain correct stepping state acrossyield/awaitboundaries. -
Ensure step-over does not "leak" into a resumed generator.
-
[ ] 3.6
BRANCH_LEFT/BRANCH_RIGHTfor coverage - Use for coverage-aware breakpoints (feature idea §8).
- Count branch executions with zero overhead when not actively debugging.
- Use feature detection:
hasattr(sys.monitoring.events, 'BRANCH_LEFT')(3.14+). -
Fall back to deprecated
BRANCHevent on 3.12–3.13. -
[ ] 3.7 Tests for Phase 3
- Exception breakpoint fires on matching type.
- "User-unhandled" exception correctly ignored when caught in user code.
- Instruction stepping produces correct offsets.
- Generator step-over stays in caller.
- Async
awaitstepping works correctly.
Phase 4 — Performance optimisation & legacy deprecation
- [ ] 4.1 Remove Cython dependency on 3.12+
- The
_frame_evaluator.pyx/.cextension exists to provide a C-level frame-eval hook. Withsys.monitoring, this is unnecessary — the VM handles event dispatch natively. - Make Cython optional/legacy; skip compilation on 3.12+.
-
Update
build/frame-eval/andscripts/build_frame_eval.py. -
[ ] 4.2 Deprecate
BytecodeModifieron 3.12+ sys.monitoring.set_local_events()+DISABLEreplaces bytecode injection for breakpoint checks.- Keep
BytecodeModifierfor the settrace path only. -
Add deprecation notice in docstrings.
-
[ ] 4.3 Optimise
DISABLEusage inLINEcallback - Profile: return
DISABLEaggressively for every line that is NOT a breakpoint line to minimise callback invocations. -
Ensure
restart_events()is called only when breakpoints actually change (batch updates). -
[ ] 4.4 Optimise code-object registry memory
-
Use
weakref.WeakSetfor code-object references to avoid preventing garbage collection of unloaded modules. -
[ ] 4.5 Benchmarks
- Compare wall-clock overhead:
- (a) settrace + frame_eval (current).
- (b) sys.monitoring (naive).
- (c) sys.monitoring +
DISABLEoptimisation. - (d) no debugger attached.
- Target: < 5% overhead for (c) with a small number of breakpoints set.
- Publish results in
doc/reference/.
Phase 5 — Read-access watchpoints (feature idea §12)
- [ ] 5.1 Read watchpoints via
INSTRUCTIONevents - Use
INSTRUCTIONevents on targeted code objects. - Inspect opcode at
instruction_offsetfor variable-read operations:LOAD_NAME,LOAD_FAST,LOAD_GLOBAL,LOAD_ATTR. - Match against watched variable names.
-
Return
DISABLEfor non-matching instructions to minimise overhead. -
[ ] 5.2 Graceful fallback on < 3.12
- On Python < 3.12, fall back to the existing write-only
DataBreakpointStatebehaviour. -
Surface a capabilities flag in DAP
initializeresponse:supportsReadWatchpoints: Trueonly on 3.12+. -
[ ] 5.3 Tests for read watchpoints
- Read of a watched local variable triggers a stop.
- Read of a watched global triggers a stop.
- Read of a watched attribute triggers a stop.
- Unwatched reads do not trigger (verify
DISABLE). - Fallback on < 3.12 produces write-only behaviour.
Files Affected
| Action | Path | Phase |
|---|---|---|
| New | dapper/_frame_eval/tracing_backend.py |
1 |
| New | dapper/_frame_eval/settrace_backend.py |
1 |
| New | dapper/_frame_eval/monitoring_backend.py |
2 |
| Modify | dapper/_frame_eval/debugger_integration.py |
2 |
| New | tests/unit/test_sys_monitoring_backend.py |
2 |
| Modify | dapper/_frame_eval/config.py |
1 |
| Modify | dapper/_frame_eval/frame_eval_main.py |
1 |
| Modify | dapper/_frame_eval/compatibility_policy.py |
1 |
| Modify | dapper/_frame_eval/debugger_integration.py |
2 |
| Modify | dapper/core/debugger_bdb.py |
2 |
| Deprecate | dapper/_frame_eval/_frame_evaluator.pyx |
4 |
| Deprecate | dapper/_frame_eval/modify_bytecode.py |
4 |
| Modify | scripts/build_frame_eval.py |
4 |
Risks & Mitigations
| Risk | Mitigation |
|---|---|
LINE callback receives (code, line_number) — no frame object |
Use sys._getframe(1) inside callback, or track frames via PY_START; benchmark both approaches |
restart_events() is global (all tools) |
Only call when breakpoints actually change; document that Dapper may re-enable events for other tools (per CPython semantics) |
Another tool already holds DEBUGGER_ID slot |
Call sys.monitoring.get_tool(DEBUGGER_ID) at startup; if taken, fall back to SettraceBackend with a warning |
| Python 3.12/3.13 vs 3.14 behaviour differences | BRANCH deprecated in 3.14 in favour of BRANCH_LEFT/BRANCH_RIGHT; use hasattr() feature detection |
| Thread safety of callback registration | All register_callback / set_events calls happen on the main thread or under a lock; callbacks themselves are thread-safe (stateless lookups into immutable snapshots) |
sys._getframe() may be slow or restricted |
Profile; if problematic, switch to frame tracking via PY_START with a thread-local frame stack |