fix: enforce thread ownership at the Sippy/PJSUA2 boundary
Three thread domains were mutating shared dicts with no locks: Sippy's ED thread wrote _legs/_registered_devices directly from SIP handlers, the asyncio loop wrote them from make_call/hangup, and run_in_executor(None, ...) had default-pool threads driving sippy UA objects. AudioTap.feed() pushed into an asyncio.Queue (not thread-safe) from the PJSUA2 thread. New ownership rule, enforced structurally: - The asyncio loop owns all app-visible state; the only mutator is the new _on_engine_event funnel. Sippy handlers extract plain strings on the ED thread and post via run_coroutine_threadsafe. - The ED thread owns sippy objects plus _ed_ua_to_leg/_ed_leg_to_ua; loop-side commands (INVITE/BYE/DTMF/trunk register) hop over via ED2.callFromThread. UA references no longer live on SipCallLeg. - AudioTap captures its loop and feed() hops via call_soon_threadsafe. - Fix ED import: installed sippy 2.x exposes ED2, not ED — the old import could never start the event loop. Also: - Wire the never-connected on_leg_state_change callback: outbound ringing/connected/terminated now reaches CallManager; a call ends when its last leg terminates (transfers keep it alive). Adds CallManager.unmap_leg/legs_for_call. - AudioClassifier.classify(): async entry that runs the FFT work in asyncio.to_thread and updates history on the loop — all four hold_slayer call sites now route through it, fixing both the loop-blocking and the 2-of-4 history gap. DTMF Goertzel loop replaced by the equivalent vectorized DFT-bin power. - Task hygiene: gateway.spawn() tracks hold-slayer/receptionist tasks and stop() cancels them; recording safety-timeout task is retained and cancelled on stop_recording; engine tracks incoming-call dispatch tasks. 10 new tests: funnel events from a foreign thread, auto-answer fallback, AudioTap cross-thread feed, classifier history, leg-state → call status (including no stomping of ON_HOLD), stop() cancellation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ class RecordingService:
|
||||
self._max_recording_seconds = max_recording_seconds
|
||||
self._sample_rate = sample_rate
|
||||
self._active_recordings: dict[str, RecordingSession] = {}
|
||||
self._timeout_tasks: dict[str, asyncio.Task] = {}
|
||||
self._metadata: list[dict] = []
|
||||
|
||||
async def start(self) -> None:
|
||||
@@ -101,8 +102,8 @@ class RecordingService:
|
||||
self._active_recordings[call_id] = session
|
||||
logger.info(f"🔴 Recording started: {call_id} → {filepath_mixed}")
|
||||
|
||||
# Safety timeout
|
||||
asyncio.create_task(
|
||||
# Safety timeout — tracked so it can be cancelled and isn't GC'd
|
||||
self._timeout_tasks[call_id] = asyncio.create_task(
|
||||
self._recording_timeout(call_id),
|
||||
name=f"rec_timeout_{call_id}",
|
||||
)
|
||||
@@ -115,6 +116,14 @@ class RecordingService:
|
||||
media_pipeline=None,
|
||||
) -> Optional["RecordingSession"]:
|
||||
"""Stop recording a call and finalize the WAV file."""
|
||||
timeout_task = self._timeout_tasks.pop(call_id, None)
|
||||
if (
|
||||
timeout_task is not None
|
||||
and timeout_task is not asyncio.current_task()
|
||||
and not timeout_task.done()
|
||||
):
|
||||
timeout_task.cancel()
|
||||
|
||||
session = self._active_recordings.pop(call_id, None)
|
||||
if not session:
|
||||
logger.warning(f" No active recording for {call_id}")
|
||||
|
||||
Reference in New Issue
Block a user