Concurrency Model
Dapper's adapter uses a single asyncio event loop with a small set of thread-safe helpers for cross-thread task scheduling. Understanding this model is important when adding new I/O paths, writing tests, or working with the IPC layer.
Event Loop Architecture
PyDebugger (defined in dapper/adapter/debugger/py_debugger.py and exported via dapper/adapter/server.py) owns the asyncio event loop for the lifetime of a debugging session. The following components run outside the loop on dedicated threads:
- stdin reader thread — reads raw DAP messages from the client and dispatches them onto the loop.
- stdout writer thread — serialises outgoing DAP messages and writes them to stdout.
- process wait threads — wait for debugged sub-processes to exit and signal the loop when they do.
All mutable debugger state (breakpoints, thread registry, session state) is accessed exclusively from the event loop. Cross-thread state mutations must be scheduled via spawn_threadsafe (see below) rather than accessed directly, which would introduce data races.
spawn_threadsafe and scheduling from other threads
The only built-in helper still provided by PyDebugger and
DebugAdapterServer is spawn_threadsafe. If you're already running on
one of these loops, you can simply use loop.create_task(...) or
asyncio.create_task(...) directly; the helper exists to safely schedule
work from arbitrary threads.
-
spawn_threadsafe(factory) -
Safe to call from any thread, including threads with no running event loop. The argument is a zero-argument callable that returns a coroutine. The factory is executed on the adapter loop, and if it returns an awaitable the helper wraps it in a task and tracks it in
_bg_tasksso shutdown can await it. - Test-friendly behaviour: if the debugger loop is not yet running
but another loop is active (e.g. pytest's event loop), the factory
executes immediately on that active loop. This ensures that mocks
observing
send_eventsee results synchronously in tests without requiring the debugger loop to be fully started.
Minimal usage example:
# From a thread reading child process output:
def _read_stdout(self, data: bytes) -> None:
self._debugger.spawn_threadsafe(lambda: self._handle_output(data))
Thread Safety Guidelines
- Always use
spawn_threadsafefrom threads. Never callasyncioprimitives likeloop.call_soondirectly — letspawn_threadsafecentralise task tracking and error handling. - Prefer factory lambdas. Pass
lambda: some_coro(arg)rather than a pre-created coroutine object. Creating coroutines off the loop thread can attach them to the wrong running loop. - Do not use
asyncio.run_coroutine_threadsafedirectly unless you specifically need the returnedFuturefor synchronisation (e.g. blocking until a result is ready). Otherwise,spawn_threadsafeis simpler and integrates with the internal task registry. - Discard tasks when done. If you cancel or await a task manually, also discard it from
_bg_tasksto prevent unbounded memory growth. - IPC is managed by
IPCManager. TheIPCManager(dapper/ipc/ipc_manager.py) handles all inter-process communication. It delegates transport details toConnectionBaseimplementations viaTransportFactory. Do not create raw sockets or pipes without going through this layer.