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

2.7 KiB

description, paths
description paths
the Sippy/PJSUA2 OS-thread boundary — two funnels, who owns what state, never cross it directly
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.