Stage 2: thread ownership at the SIP boundary, leg-state wiring, task hygiene #2
Reference in New Issue
Block a user
Delete Branch "feature/stage2-concurrency"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Second stage of the architecture-review roadmap. Stacked on #1 (
feature/stage1-agent-surface) — merge that first; this diff shows only Stage 2.The problem
Three thread domains were touching shared state with no synchronization:
_legs/_registered_devicesdirectly from SIP handlers.make_call/hangup.run_in_executor(None, ...)put default-pool threads in charge of sippy UA objects — a third domain.Plus:
AudioTap.feed()calledasyncio.Queue.put_nowaitfrom the PJSUA2 thread (not thread-safe), the classifier ran FFTs and a per-sample Python Goertzel loop inline on the event loop,on_leg_state_changewas never wired (outbound call state never reached CallManager), and hold-slayer/receptionist/recording-timeout tasks were barecreate_taskcalls — unretained and never cancelled.The ownership rule (now structural, no locks)
_on_engine_eventfunnel; Sippy handlers extract plain strings on the ED thread and post{leg_id, kind, ...}payloads viarun_coroutine_threadsafe._ed_ua_to_leg/_ed_leg_to_uamaps (replacing the UA scans over_legs). Loop-side commands hop over viaED2.callFromThread. UA references no longer live onSipCallLeg.leg_idstrings are the only tokens that cross.Found while implementing: the installed sippy 2.x exposes
ED2, notED— the oldfrom sippy.Core.EventDispatcher import EDraised ImportError inside the thread runner, so the Sippy event loop could never start ("Sippy event loop crashed" swallowed in logs). Fixed as part of the funnel work. Note theUA(event_cb=controller)usage still has 2.x API drift; full sippy bring-up is Stage 4 territory (explicit engine mode).Also in this stage
CallManager.unmap_leg()/legs_for_call()added.AudioClassifier.classify()runs the pureclassify_chunkinasyncio.to_threadand updates history on the loop. All four hold_slayer call sites route through it — fixing the loop-blocking and the 2-of-4 history-update gap in one move. The DTMF Goertzel per-sample loop is replaced with the mathematically-equal DFT-bin power (vectorized); all 18 classifier tests unchanged and passing.gateway.spawn()tracks per-call tasks,stop()cancels and gathers them; the recording safety-timeout is retained and cancelled onstop_recording(with a guard so the fired timeout doesn't cancel itself); the engine tracks incoming-call dispatch tasks.AudioTapcaptures its loop at construction;feed()hops viacall_soon_threadsafewith drop-oldest on overflow.Tests & verification
tests/test_concurrency.py: funnel events posted from a real foreign thread (register/re-register/deregister, INVITE auto-answer, BYE, DTMF, trunk), AudioTap cross-thread feed, classifier history recording, leg-state → call-status propagation incl. the ON_HOLD no-stomp case, and stop() cancelling spawned tasks.main.🤖 Generated with Claude Code