Skip to content

Channel hangup/answer callback registries leak one closure per call (unbounded memory + O(n²) fire) #121

Description

@ryanmurf

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.rsHANGUP_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:768register_answer_callback(...)
  • crates/asterisk-sip/src/event_handler.rs:775register_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

  1. 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.
  2. 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.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions