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:
@@ -5,6 +5,7 @@ Ties together SIP engine, call manager, event bus, and all services.
|
||||
This is the top-level object that FastAPI and MCP talk to.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
@@ -57,6 +58,7 @@ def _build_sip_engine(settings: Settings, gateway: "AIPSTNGateway") -> SIPEngine
|
||||
domain=gw_sip.domain,
|
||||
did=trunk.did,
|
||||
media_pipeline=gateway.media_pipeline,
|
||||
on_leg_state_change=gateway._on_sip_leg_state,
|
||||
on_device_registered=gateway._on_sip_device_registered,
|
||||
on_incoming_call=gateway._on_sip_incoming_call,
|
||||
)
|
||||
@@ -101,9 +103,20 @@ class AIPSTNGateway:
|
||||
# Device registry (loaded from DB on start)
|
||||
self._devices: dict[str, Device] = {}
|
||||
|
||||
# Background tasks (per-call services, receptionist sessions) —
|
||||
# tracked so shutdown can cancel them and GC can't drop them
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
|
||||
# Startup time
|
||||
self._started_at: Optional[datetime] = None
|
||||
|
||||
def spawn(self, coro, name: str) -> asyncio.Task:
|
||||
"""Launch a tracked background task."""
|
||||
task = asyncio.get_running_loop().create_task(coro, name=name)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
return task
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, sip_engine: Optional[SIPEngine] = None) -> "AIPSTNGateway":
|
||||
"""Create gateway from environment config."""
|
||||
@@ -176,6 +189,12 @@ class AIPSTNGateway:
|
||||
"""Gracefully shut down."""
|
||||
logger.info("Shutting down AI PSTN Gateway...")
|
||||
|
||||
# Cancel per-call background tasks before tearing down their deps
|
||||
for task in list(self._tasks):
|
||||
task.cancel()
|
||||
if self._tasks:
|
||||
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||
|
||||
# End all active calls
|
||||
for call_id in list(self.call_manager.active_calls.keys()):
|
||||
call = self.call_manager.get_call(call_id)
|
||||
@@ -278,8 +297,7 @@ class AIPSTNGateway:
|
||||
tts=self._tts,
|
||||
)
|
||||
# Launch as background task — don't block
|
||||
import asyncio
|
||||
asyncio.create_task(
|
||||
self.spawn(
|
||||
hold_slayer.run(call, sip_leg_id, call_flow_id),
|
||||
name=f"holdslayer_{call.id}",
|
||||
)
|
||||
@@ -366,6 +384,33 @@ class AIPSTNGateway:
|
||||
if device:
|
||||
logger.info(f"📱 Device unregistered: {device.name}")
|
||||
|
||||
async def _on_sip_leg_state(self, leg_id: str, state: str) -> None:
|
||||
"""
|
||||
SIP leg state change from the engine (already on the loop).
|
||||
|
||||
Maps leg transitions onto call status. Status only moves
|
||||
forward from the dialing phase — hold-slayer/receptionist
|
||||
states (ON_HOLD, NAVIGATING_IVR, …) are never stomped by a
|
||||
late ringing/connected signal from a second leg.
|
||||
"""
|
||||
call = self.call_manager.get_call_for_leg(leg_id)
|
||||
if call is None:
|
||||
return
|
||||
|
||||
if state == "ringing" and call.status == CallStatus.INITIATING:
|
||||
await self.call_manager.update_status(call.id, CallStatus.RINGING)
|
||||
elif state == "connected" and call.status in (
|
||||
CallStatus.INITIATING,
|
||||
CallStatus.RINGING,
|
||||
):
|
||||
await self.call_manager.update_status(call.id, CallStatus.CONNECTED)
|
||||
elif state == "terminated":
|
||||
self.call_manager.unmap_leg(leg_id)
|
||||
# End the call only when its last leg is gone (a transfer
|
||||
# keeps the call alive on the device leg)
|
||||
if not self.call_manager.legs_for_call(call.id):
|
||||
await self.call_manager.end_call(call.id)
|
||||
|
||||
async def _on_sip_device_registered(
|
||||
self, aor: str, contact: str, expires: int
|
||||
) -> None:
|
||||
@@ -487,8 +532,7 @@ class AIPSTNGateway:
|
||||
|
||||
# Hand off to the AI Receptionist
|
||||
if self._receptionist is not None and self.settings.receptionist.enabled:
|
||||
import asyncio as _asyncio
|
||||
_asyncio.create_task(
|
||||
self.spawn(
|
||||
self._receptionist.handle(call, leg_id, decision),
|
||||
name=f"receptionist_{call.id}",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user