Skip to content

Fix ABBA deadlock between allocation cleanup and ICE-ufrag registration - #9

Merged
srperens merged 2 commits into
mainfrom
fix/allocation-table-deadlock
May 27, 2026
Merged

srperens merged 2 commits into
mainfrom
fix/allocation-table-deadlock

Conversation

@srperens

Copy link
Copy Markdown
Owner

Why

On 2026-05-27 the production server hung silently for ~1h40m (no crash, no panic, no log output) until a manual docker restart. Root cause: an ABBA lock-order deadlock in AllocationTable.

AllocationTable keeps a primary allocations DashMap plus secondary index maps. The cleanup_* paths hold an allocations shard write lock (via retain) and then mutate the secondary indices — order allocations → secondary. register_ice_ufrags did the reverse: it held a by_ice_ufrag write lock (entry API) and then acquired an allocations read lock via get().

On the multi-thread tokio runtime, a concurrent cleanup and register on colliding shards park both threads forever. Because packets are processed inline on the recv loop, the whole server then goes silent. It ran for ~5.5 weeks before the rare shard collision finally hit; the last log line before the freeze was Removing inactive allocation — emitted from inside cleanup_inactive's retain closure, exactly where the cleanup side parks.

What

Commit 1 — fix(table)

  • Reorder register_ice_ufrags to acquire allocations first, matching the cleanup paths. The atomic by_ice_ufrag dedup and exact return semantics are preserved.
  • Document the lock-order invariant on the AllocationTable struct (allocations before any secondary index, never the reverse).
  • Add a watchdog'd contention regression test that drives register_ice_ufrags and cleanup_inactive from multiple threads. Validated: it deadlocks (and fails via the watchdog) on the pre-fix order, and passes after.

Commit 2 — refactor(handler)

  • The handlers bound a DashMap guard and then awaited socket.send_to(...) while it was alive (handle_send held both the sender's and each target's guard across every relay send). A guard is a shard RwLock guard; holding it across network I/O blocks every writer on that shard — including the cleanup task — for the full send duration.
  • Reworked handle_binding_request, handle_client_response, handle_refresh, handle_create_permission, handle_channel_bind, and handle_send to snapshot the owned data they need, drop the guard, then await. Behavior is preserved; the rare allocation-reaped-mid-request races resolve to the same error/skip paths.

Verification

  • cargo clippy --all-targets clean.
  • cargo test — 36 passed (35 existing + new regression test).
  • Regression test validated against the buggy lock order (deadlocks → watchdog fails it).

Caveat: there are no integration tests for the WebRTC routing in handle_send, so commit 2 is verified by compile + clippy + unit tests + manual reasoning only. A WHEP smoke test after deploy is the real confirmation.

🤖 Generated with Claude Code

srperens and others added 2 commits May 27, 2026 15:10
…anup

AllocationTable keeps a primary `allocations` map plus secondary index
DashMaps. The cleanup paths (cleanup_expired/cleanup_inactive/
cleanup_orphaned_senders) hold an `allocations` shard write lock via
`retain` and then mutate the secondary indices, establishing the order
`allocations -> secondary`. register_ice_ufrags did the reverse: it held
a `by_ice_ufrag` write lock (entry API) and then acquired an `allocations`
read lock via get(). On the multi-thread runtime a concurrent cleanup and
register on colliding shards deadlock (ABBA): neither thread crashes or
logs, the inline packet loop blocks on the lock, and the whole server goes
silent until restarted.

Reorder register_ice_ufrags to acquire `allocations` first, matching the
cleanup paths, preserving the atomic by_ice_ufrag dedup and exact return
semantics. Document the lock-order invariant on the AllocationTable struct.

Add a contention regression test that drives register_ice_ufrags and
cleanup_inactive from multiple threads with a watchdog: if the inverse
order is reintroduced the workers deadlock and the watchdog fails the test
instead of hanging the suite. Verified it fails on the pre-fix order and
passes after.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The TURN handlers bound a DashMap `Ref`/`get_by_client` guard and then
awaited `socket.send_to(...)` while it was still alive (handle_send held
both the sender's and each target's guard across every relay send). A
DashMap guard is a shard RwLock guard; holding it across network I/O
blocks every writer on that shard -- including the periodic allocation
cleanup -- for the full duration of the send, and amplifies lock
contention under load.

Rework handle_binding_request, handle_client_response, handle_refresh,
handle_create_permission, handle_channel_bind and handle_send to snapshot
the owned data they need (client addresses, ids, permission/ufrag state,
auth username), drop the guard, and only then await the sends. Relay fan-out
now collects target client addresses first and sends guard-free; activity
timers are updated under short-lived re-acquired guards with no await held.
Behavior is preserved; the rare allocation-reaped-mid-request races resolve
to the same error/skip paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@srperens
srperens merged commit cf5814a into main May 27, 2026
3 checks passed
srperens added a commit that referenced this pull request May 27, 2026
Includes the AllocationTable ABBA-deadlock fix (#9) and the handler
guard-across-await refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@srperens
srperens deleted the fix/allocation-table-deadlock branch September 14, 2026 18:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant