--- description: the Sippy/PJSUA2 OS-thread boundary — two funnels, who owns what state, never cross it directly paths: - "core/sippy_engine.py" - "core/sip_engine.py" - "core/media_pipeline.py" - "core/call_manager.py" - "core/gateway.py" --- # The thread boundary (the invariant that keeps this app sane) The README says "single-process async." That's true at the surface, but under the SIP engine there are **two execution contexts**: the asyncio event loop, and a dedicated **Sippy/PJSUA2 OS thread** running the `ED2` event dispatcher. Almost every hard-to-debug class of bug in a telephony gateway comes from touching one context's state from the other. This app avoids that with exactly one funnel each way. Preserve them. - **Who owns what:** - *Sippy thread* owns the Sippy UA objects and the `ED2` dispatcher. State: `_ed_ua_to_leg`, `_ed_leg_to_ua` (the "ED-thread-owned state" maps). Only touch these from a Sippy handler or a `_run_on_sippy` closure. - *asyncio loop* owns everything else: `_legs`, `_bridges`, `_registered_devices`, the `EventBus`, the `CallManager`, the media pipeline wiring. - **Cross thread → loop only via `_post_from_ed`.** It calls `asyncio.run_coroutine_threadsafe(self._on_engine_event(kind, data), self._loop)`. `_on_engine_event` is **the single funnel** where Sippy-thread events mutate loop-owned state, and it runs on the loop. New Sippy-side events post through here with a new `kind`; they do **not** reach into `_legs`/`EventBus` directly from the handler. - **Cross loop → thread only via `_run_on_sippy`.** It uses `ED2.callFromThread(fn)` so `fn` runs where the Sippy objects live. In simulation mode (no `sippy` import) it runs `fn` inline — keep that fallback so tests and stub mode work without the native library. Anything that manipulates a UA object goes through here. - **Never:** read/write a Sippy UA object from the loop; never mutate `_legs`, publish an event, or touch the `CallManager` from inside a raw Sippy callback without going through `_post_from_ed`. If you find yourself wanting to, you're about to introduce a data race — add a `kind` to the funnel instead. - **Background tasks are tracked, both sides.** The gateway's `spawn()` and the engine's `_spawn()` add tasks to a `_tasks` set with a done-callback that discards them, so shutdown can cancel them and the GC can't drop a live coroutine. Launch per-call/background work through these, not a bare `asyncio.create_task` you forget to hold a reference to. - **`MockSIPEngine` has no thread.** Tests run against it; it satisfies the same `SIPEngine` interface synchronously/async-inline. When you extend the real engine's behaviour, extend the mock to match, or tests will pass against a fiction.