Files
hold-slayer/.claude/rules/concurrency-threads.md
Robert Helewka 4a3c14d4af
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s
docs: add Claude AI assistant rules and configuration
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.
2026-07-28 19:01:38 -04:00

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.