Add comprehensive rule documentation for AI-assisted development covering authentication surfaces, outbound-call safety invariants, and other project conventions to guide Claude's understanding of critical system behaviors.
2.7 KiB
description, paths
| description | paths | |||||
|---|---|---|---|---|---|---|
| the Sippy/PJSUA2 OS-thread boundary — two funnels, who owns what state, never cross it directly |
|
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
ED2dispatcher. 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_sippyclosure. - asyncio loop owns everything else:
_legs,_bridges,_registered_devices, theEventBus, theCallManager, the media pipeline wiring.
- Sippy thread owns the Sippy UA objects and the
-
Cross thread → loop only via
_post_from_ed. It callsasyncio.run_coroutine_threadsafe(self._on_engine_event(kind, data), self._loop)._on_engine_eventis 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 newkind; they do not reach into_legs/EventBusdirectly from the handler. -
Cross loop → thread only via
_run_on_sippy. It usesED2.callFromThread(fn)sofnruns where the Sippy objects live. In simulation mode (nosippyimport) it runsfninline — 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 theCallManagerfrom 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 akindto the funnel instead. -
Background tasks are tracked, both sides. The gateway's
spawn()and the engine's_spawn()add tasks to a_tasksset 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 bareasyncio.create_taskyou forget to hold a reference to. -
MockSIPEnginehas no thread. Tests run against it; it satisfies the sameSIPEngineinterface synchronously/async-inline. When you extend the real engine's behaviour, extend the mock to match, or tests will pass against a fiction.