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.
56 lines
2.7 KiB
Markdown
56 lines
2.7 KiB
Markdown
---
|
|
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.
|