Summary
Two process-global channel-lifecycle callback registries were append-only, so
every call leaked closures forever and the per-event fire cost grew O(n). Over the
M5 500-call soak this is a slow OOM plus quadratic CPU on hangup/answer.
crates/asterisk-core/src/channel/mod.rs — HANGUP_CALLBACKS / ANSWER_CALLBACKS
were Vec<Callback> with a push-only register_* API and no removal path.
Leak sequence
Every inbound INVITE registers two per-call closures keyed to one channel's
unique_id:
crates/asterisk-sip/src/event_handler.rs:768 — register_answer_callback(...)
crates/asterisk-sip/src/event_handler.rs:775 — register_hangup_callback(...)
They are never removed. Additional per-join / per-pair registrations:
crates/asterisk-apps/src/confbridge.rs:1465 — hangup callback per ConfBridge join
crates/asterisk-channels/src/local.rs:301 — answer callback per Local pair
Each closure captures an Arc<Notify> + unique_id String and stays resident for
the process lifetime. After N calls the vector holds ~2N closures.
Impact over a soak
- Unbounded memory. ~2 closures leaked per inbound call. 500 calls ≈ 1000
dead closures; a real soak (thousands of calls) grows without bound → OOM. This
is invisible to the M4 exact-baseline harness, which counts transaction/channel/
call-state/NOTIFY maps but not these registries.
- O(n²) fire cost.
fire_hangup_callbacks / fire_answer_callbacks scan the
entire vector on every hangup/answer, and almost all entries are stale closures
that fail their uid == guard. The Nth hangup scans ~2N closures, so N calls do
O(N²) callback invocations — measurable latency creep as the soak runs.
- Correctness smell. A stale closure for a since-reused
unique_id can still fire.
Fix (PR incoming)
Key both registries by a monotonic id (HashMap<u64, Callback>) and add a scoped
RAII registration API (register_{hangup,answer}_callback_scoped) returning a
#[must_use] handle whose Drop removes the entry. The four per-call sites migrate
to the scoped API and hold the handle for the call/leg lifetime. The permanent
register_* API is retained for any process-lifetime subscriber (e.g. CDR). Adds
registered_{hangup,answer}_callbacks() count accessors so the soak harness can
assert the registries return to baseline. Fixes both the leak and the quadratic.
Axis: concurrency correctness & resource lifetime / leak-freedom (M5 500-call soak gate).
Summary
Two process-global channel-lifecycle callback registries were append-only, so
every call leaked closures forever and the per-event fire cost grew O(n). Over the
M5 500-call soak this is a slow OOM plus quadratic CPU on hangup/answer.
crates/asterisk-core/src/channel/mod.rs—HANGUP_CALLBACKS/ANSWER_CALLBACKSwere
Vec<Callback>with apush-onlyregister_*API and no removal path.Leak sequence
Every inbound INVITE registers two per-call closures keyed to one channel's
unique_id:crates/asterisk-sip/src/event_handler.rs:768—register_answer_callback(...)crates/asterisk-sip/src/event_handler.rs:775—register_hangup_callback(...)They are never removed. Additional per-join / per-pair registrations:
crates/asterisk-apps/src/confbridge.rs:1465— hangup callback per ConfBridge joincrates/asterisk-channels/src/local.rs:301— answer callback per Local pairEach closure captures an
Arc<Notify>+unique_idStringand stays resident forthe process lifetime. After N calls the vector holds ~2N closures.
Impact over a soak
dead closures; a real soak (thousands of calls) grows without bound → OOM. This
is invisible to the M4 exact-baseline harness, which counts transaction/channel/
call-state/NOTIFY maps but not these registries.
fire_hangup_callbacks/fire_answer_callbacksscan theentire vector on every hangup/answer, and almost all entries are stale closures
that fail their
uid ==guard. The Nth hangup scans ~2N closures, so N calls doO(N²) callback invocations — measurable latency creep as the soak runs.
unique_idcan still fire.Fix (PR incoming)
Key both registries by a monotonic id (
HashMap<u64, Callback>) and add a scopedRAII registration API (
register_{hangup,answer}_callback_scoped) returning a#[must_use]handle whoseDropremoves the entry. The four per-call sites migrateto the scoped API and hold the handle for the call/leg lifetime. The permanent
register_*API is retained for any process-lifetime subscriber (e.g. CDR). Addsregistered_{hangup,answer}_callbacks()count accessors so the soak harness canassert the registries return to baseline. Fixes both the leak and the quadratic.
Axis: concurrency correctness & resource lifetime / leak-freedom (M5 500-call soak gate).