fix(iq): cancellation-safe IQ response waiters (unblocks try_join!) + named fan-out consts - #978
Conversation
… is_on_whatsapp send_and_wait_iq registered a response_waiters entry and only removed it on the send-fail / timeout / shutdown paths — never when the future itself was dropped mid-await (cancellation). So a fail-fast futures::try_join! over two IQs would drop the losing side the instant its sibling errored and leak that waiter; a lingering waiter suppresses keepalives for the life of the connection. That's why is_on_whatsapp had to use join! (await both) instead of try_join!. Fix the root cause with an RAII ResponseWaiterGuard that removes the entry on Drop, covering every exit path including cancellation. This requires the map to be lockable from Drop, so response_waiters moves from an async Mutex to a std::sync::Mutex (matching the node_waiters sibling: a trivial critical section never held across an await; the one drain site is block-scoped so the guard is released before its awaits). The four hand-rolled removes in send_and_wait_iq collapse into the guard. is_on_whatsapp now uses try_join! for fail-fast. Also name the bounded fan-out concurrency limits (buffer_unordered(16) at the session-probe, device-list, status-LID, companion-identity, and LID→PN sites) as per-module consts, matching the existing HISTORY_SYNC_CONCURRENCY / MEDIA_REUPLOAD_CONCURRENCY style, so they're greppable and independently tunable. Tests: ResponseWaiterGuard removes-on-drop and no-op-when-resolved; existing ack, voip no-ack, and keepalive suites cover the mutex change. 921 lib tests pass; clippy clean; wasm32 --no-default-features builds.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
📝 WalkthroughWalkthroughThe ChangesResponse Waiter Synchronization Refactor
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Caller
participant send_and_wait_iq
participant ResponseWaiterGuard
participant response_waiters
participant node_io
Caller->>send_and_wait_iq: send IQ, await response
send_and_wait_iq->>response_waiters: insert(id, tx)
send_and_wait_iq->>ResponseWaiterGuard: create guard(id, waiters)
alt IQ response arrives
node_io->>response_waiters: remove(id), resolve tx
send_and_wait_iq->>Caller: return response
ResponseWaiterGuard-->>ResponseWaiterGuard: drop (no-op, already removed)
else timeout / cancellation
send_and_wait_iq-->>ResponseWaiterGuard: future dropped/timed out
ResponseWaiterGuard->>response_waiters: remove(id) on Drop
send_and_wait_iq->>Caller: return IqError::Timeout
end
Possibly related PRs
Suggested reviewers: Look, this is the kind of change I like: focused, deliberate, and it removes wasted async overhead from a hot path — that's what winning looks like. But I need every poison-recovery unwrap and the new 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
| Filename | Overview |
|---|---|
| src/request.rs | Introduces ResponseWaiterGuard RAII type that removes the waiter entry on every exit path including cancellation; collapses four hand-rolled remove() calls into the guard's Drop; adds two unit tests. |
| src/client.rs | Exports ResponseWaiterMap type alias; migrates response_waiters field from async to std::sync::Mutex, enabling Drop-based cleanup. |
| src/client/accessors.rs | Adds response_waiters_guard() helper centralising poison-recovering unwrap_or_else(into_inner). |
| src/client/lifecycle.rs | Block-scopes the drain in cleanup_connection_state so the sync guard drops before subsequent .awaits, keeping the future Send. |
| src/client/messaging.rs | Removes async keyword from register_ack_waiter; all three call sites drop their .await. |
| src/client/node_io.rs | Replaces lock().await.remove() with response_waiters_guard().remove() at both IQ and ACK dispatch sites; safe since no await appears in the if-body. |
| src/features/contacts.rs | Replaces join! with try_join!; now safe because send_and_wait_iq uses ResponseWaiterGuard. |
| src/keepalive.rs | Replaces async lock().await.is_empty() with sync response_waiters_guard().is_empty(); behaviour unchanged. |
| src/send/mod.rs | Removes .await from two register_ack_waiter sites; collapses three lock().await.remove() calls; manual cleanup on send-failure preserved for ACK waiters outside the guard scope. |
| src/voip/facade.rs | Collapses five verbose lock().await.remove() blocks into response_waiters_guard().remove(); updates tests accordingly. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Caller
participant send_and_wait_iq
participant ResponseWaiterMap
participant ResponseWaiterGuard
participant node_io
Caller->>send_and_wait_iq: call (req_id)
send_and_wait_iq->>ResponseWaiterMap: insert(req_id, tx)
send_and_wait_iq->>ResponseWaiterGuard: "create (holds Arc<Mutex<Map>>, req_id)"
alt Normal success path
node_io->>ResponseWaiterMap: remove(req_id) — sends on tx
send_and_wait_iq->>Caller: Ok(response)
ResponseWaiterGuard->>ResponseWaiterMap: remove(req_id) [no-op, already gone]
else Send failure / timeout / shutdown
send_and_wait_iq->>Caller: Err(...)
ResponseWaiterGuard->>ResponseWaiterMap: remove(req_id) [cleans up]
else Cancellation via try_join! drop
Note over send_and_wait_iq,ResponseWaiterGuard: Future dropped mid-await
ResponseWaiterGuard->>ResponseWaiterMap: remove(req_id) [Drop — only path that catches this]
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Caller
participant send_and_wait_iq
participant ResponseWaiterMap
participant ResponseWaiterGuard
participant node_io
Caller->>send_and_wait_iq: call (req_id)
send_and_wait_iq->>ResponseWaiterMap: insert(req_id, tx)
send_and_wait_iq->>ResponseWaiterGuard: "create (holds Arc<Mutex<Map>>, req_id)"
alt Normal success path
node_io->>ResponseWaiterMap: remove(req_id) — sends on tx
send_and_wait_iq->>Caller: Ok(response)
ResponseWaiterGuard->>ResponseWaiterMap: remove(req_id) [no-op, already gone]
else Send failure / timeout / shutdown
send_and_wait_iq->>Caller: Err(...)
ResponseWaiterGuard->>ResponseWaiterMap: remove(req_id) [cleans up]
else Cancellation via try_join! drop
Note over send_and_wait_iq,ResponseWaiterGuard: Future dropped mid-await
ResponseWaiterGuard->>ResponseWaiterMap: remove(req_id) [Drop — only path that catches this]
end
Reviews (3): Last reviewed commit: "docs: fix typo (paniced -> panicked) in ..." | Re-trigger Greptile
📦 Binary size report
.text per crate
Top movers (cargo-bloat attribution)
Baseline: |
…tion Its only await was the response_waiters lock, now a std::sync::Mutex insert, so the async fn had no await left. Make it a plain fn and drop .await at the send and voip call sites. (clippy::unused_async is nursery-only, so CI wouldn't have flagged it.)
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/send/mod.rs (1)
917-928: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftMake ACK waiters cancellation-safe too.
The cleanup only runs on send error/timeout. If the future is dropped while
send_nodeis pending—or insend_message_implduringpersist_outbound_msg_secret(...).awaitbeforespawn_phash_validationowns the receiver—the waiter can stay inresponse_waitersand suppress keepalives. Use the same RAII guard pattern for ACK waiters, or spawn/pass ownership to phash validation before any post-send await.Also applies to: 1189-1204, 1879-1939
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/send/mod.rs` around lines 917 - 928, The ACK waiter cleanup in the send path is not cancellation-safe, so a dropped future can leave entries behind in response_waiters and block keepalives. Update the ack-waiter handling in send_message_impl and the related send flow around send_node and spawn_phash_validation to use the same RAII-style guard pattern as other waiters, or transfer ownership to the phash validation task before any await that can be cancelled. Ensure the waiter is always removed when the operation ends, even if the future is dropped before send_node completes.src/voip/facade.rs (1)
538-606: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftClean up VoIP offer registration on cancellation.
If
place_callis dropped whileclient.send_node(offer).awaitis in flight, this send-failure cleanup never runs, leaving the ACK waiter plus pending/registry state behind without a returned handle to clean it up. Add a scoped guard that reaps the waiter and generation until the offer send succeeds and ownership is transferred tospawn_outgoing_relay_waiter.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/voip/facade.rs` around lines 538 - 606, The outgoing call setup in place_call leaves behind pending_outgoing_calls, the registry generation, and the ACK waiter if send_node(offer).await is canceled before it returns, because the current cleanup only runs on an Err path. Add a scoped guard around the offer send that tracks the call_id/generation and removes the response_waiters entry plus pending_outgoing_calls and registry state on drop, then explicitly disarm it only after send_node succeeds and ownership is handed off to spawn_outgoing_relay_waiter. Use the existing place_call, send_node, take_pending_if_current, registry.remove_if_current, and response_waiters symbols to locate the cleanup flow.src/client/node_io.rs (1)
431-446: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick winUse a let-chain here, like
handle_ack_responsedoes a few hundred lines down.Same file, same pattern (lock → remove(id) → act on Some), but this one nests a plain
if letinstead of folding the removal into the outer let-chain. Pick one style — the ack-waiter path already shows the preferred one.♻️ Proposed fix
if nr.tag.as_ref() == "iq" && let Some(id) = nr.get_attr("id").map(|v| v.as_str()) + && let Some(waiter) = self + .response_waiters + .lock() + .unwrap_or_else(|p| p.into_inner()) + .remove(id.as_ref()) { - // Single lock acquisition: try to remove the waiter directly. - let waiter = self - .response_waiters - .lock() - .unwrap_or_else(|p| p.into_inner()) - .remove(id.as_ref()); - if let Some(waiter) = waiter { - if waiter.send(Arc::clone(&node)).is_err() { - warn!(target: "Client/IQ", "Failed to send IQ response to waiter. Receiver was likely dropped."); - } - return; - } + if waiter.send(Arc::clone(&node)).is_err() { + warn!(target: "Client/IQ", "Failed to send IQ response to waiter. Receiver was likely dropped."); + } + return; }As per coding guidelines: "Always use let-chains (
if let Some(x) = foo && let Some(y) = x.bar { ... }) instead of nestedif letblocks."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/client/node_io.rs` around lines 431 - 446, The IQ response handling in node_io::handle_* uses a nested if let after locking response_waiters, but the project style prefers let-chains like handle_ack_response. Refactor this block to fold the remove(id) result into the outer condition, so the waiter lookup and send path use a single let-chain instead of an inner if let; keep the existing behavior and warning in the Some(waiter) case.Source: Coding guidelines
src/client.rs (1)
470-533: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAlright, this is the kind of infrastructure work I like to see — sync mutex, short critical section, RAII-friendly. Good.
One thing bugs me though:
.lock().unwrap_or_else(|p| p.into_inner())gets copy-pasted at every call site touching this field (lifecycle.rs, accessors.rs, messaging.rs, node_io.rs x2, keepalive.rs). AClientalready has a helper pattern for this (resolve_node_waiters,clear_sent_node_waitersfor thenode_waiterssibling) — consider adding something likefn response_waiters_guard(&self) -> MutexGuard<'_, ResponseWaiterMap>here so future call sites can't accidentally use a bare.lock().unwrap()and panic on poison.♻️ Proposed helper
+impl Client { + #[inline] + pub(crate) fn response_waiters_guard(&self) -> std::sync::MutexGuard<'_, ResponseWaiterMap> { + self.response_waiters + .lock() + .unwrap_or_else(|p| p.into_inner()) + } +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/client.rs` around lines 470 - 533, The new `response_waiters` sync mutex is being accessed with repeated `.lock().unwrap_or_else(|p| p.into_inner())` patterns across multiple call sites, which is easy to misuse and can lead to panic-prone direct locks later. Add a `Client` helper like `response_waiters_guard(&self) -> MutexGuard<'_, ResponseWaiterMap>` near the `response_waiters` field, and update the existing usages in the affected paths (for example the same way `resolve_node_waiters` and `clear_sent_node_waiters` encapsulate `node_waiters`) so all access goes through the helper and consistently handles poison recovery.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/request.rs`:
- Around line 39-44: ResponseWaiterGuard::drop currently removes waiters by
req_id only, so an older displaced guard can delete a newer replacement inserted
by add_waiter. Update the waiter registration/removal flow in
ResponseWaiterGuard and add_waiter to either reject duplicate req_id inserts or
associate each waiter with a unique token/generation and only remove the entry
if it still matches that token. Make sure the fix covers both the guard drop
path and the waiter map update logic referenced by
add_waiter/ResponseWaiterGuard.
---
Outside diff comments:
In `@src/client.rs`:
- Around line 470-533: The new `response_waiters` sync mutex is being accessed
with repeated `.lock().unwrap_or_else(|p| p.into_inner())` patterns across
multiple call sites, which is easy to misuse and can lead to panic-prone direct
locks later. Add a `Client` helper like `response_waiters_guard(&self) ->
MutexGuard<'_, ResponseWaiterMap>` near the `response_waiters` field, and update
the existing usages in the affected paths (for example the same way
`resolve_node_waiters` and `clear_sent_node_waiters` encapsulate `node_waiters`)
so all access goes through the helper and consistently handles poison recovery.
In `@src/client/node_io.rs`:
- Around line 431-446: The IQ response handling in node_io::handle_* uses a
nested if let after locking response_waiters, but the project style prefers
let-chains like handle_ack_response. Refactor this block to fold the remove(id)
result into the outer condition, so the waiter lookup and send path use a single
let-chain instead of an inner if let; keep the existing behavior and warning in
the Some(waiter) case.
In `@src/send/mod.rs`:
- Around line 917-928: The ACK waiter cleanup in the send path is not
cancellation-safe, so a dropped future can leave entries behind in
response_waiters and block keepalives. Update the ack-waiter handling in
send_message_impl and the related send flow around send_node and
spawn_phash_validation to use the same RAII-style guard pattern as other
waiters, or transfer ownership to the phash validation task before any await
that can be cancelled. Ensure the waiter is always removed when the operation
ends, even if the future is dropped before send_node completes.
In `@src/voip/facade.rs`:
- Around line 538-606: The outgoing call setup in place_call leaves behind
pending_outgoing_calls, the registry generation, and the ACK waiter if
send_node(offer).await is canceled before it returns, because the current
cleanup only runs on an Err path. Add a scoped guard around the offer send that
tracks the call_id/generation and removes the response_waiters entry plus
pending_outgoing_calls and registry state on drop, then explicitly disarm it
only after send_node succeeds and ownership is handed off to
spawn_outgoing_relay_waiter. Use the existing place_call, send_node,
take_pending_if_current, registry.remove_if_current, and response_waiters
symbols to locate the cleanup flow.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 09898b4e-eca6-4c58-baf3-8098040106dc
📒 Files selected for processing (15)
src/client.rssrc/client/accessors.rssrc/client/lifecycle.rssrc/client/messaging.rssrc/client/node_io.rssrc/client/sessions.rssrc/client/tests.rssrc/features/contacts.rssrc/features/groups.rssrc/keepalive.rssrc/prekeys.rssrc/request.rssrc/send/mod.rssrc/usync.rssrc/voip/facade.rs
There was a problem hiding this comment.
0 issues found across 3 files (changes from recent commits).
Requires human review: This PR switches core IQ waiter synchronization from async_lock::Mutex to std::sync::Mutex and adds a Drop guard for safety. Please verify correctness and test coverage.
Re-trigger cubic
There was a problem hiding this comment.
1 issue found and verified against the latest diff
Confidence score: 3/5
- In
src/request.rs, the drop handler removes waiters byreq_idalone, so if a newer in-flight request reuses the same ID, dropping the older future can delete the newer waiter; that can lead to missed wakeups or a request that never resolves after merge. Before merging, make waiter removal identity-safe (e.g., include a per-request generation/token or only remove when the stored waiter matches the dropping future).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…id uniqueness Review follow-ups on the sync-mutex migration: - Add Client::response_waiters_guard() (poison-recovering lock, mirroring the node_waiters helpers) and route every &self/&Client access through it, so no call site can reach for a bare .lock().unwrap() that would panic on poison. The only direct lock left is ResponseWaiterGuard::drop, which owns its Arc. - Fold the IQ-response waiter removal into the outer let-chain (matching handle_ack_response) per the repo's let-chain style guideline. - debug_assert! that a req_id isn't already in flight before inserting. req_ids come from the monotonic generate_request_id() (verified: every send_and_wait_iq caller, incl. the sole InfoQuery.id setter in pair_code, uses it), so a given id is never in flight twice — the invariant that makes the guard's remove-by-id unambiguous. The assert catches any future caller that breaks it. 921 lib tests pass; clippy clean; voip no-ack test passes.
There was a problem hiding this comment.
1 issue found across 9 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
0 issues found across 1 file (changes from recent commits).
Requires human review: Core IQ waiter refactor changes lock type and adds RAII guard. Impact on connection reliability is high; requires human review.
Re-trigger cubic
The ack path resolves the internal response_waiters map (kept allocation-free by oxidezap#827, reworked by oxidezap#978) with no public hook, so consumers have no way to observe server acks. This adds an observe-only Event::ServerAck dispatched in handle_ack_response for every <ack> that carries an id, before and independently of the waiter resolution, so it never interacts with the send/phash flow. Use cases: measuring send -> server-accept latency (which tells server-side acceptance apart from fan-out), and surfacing nack codes (463/479/...) programmatically instead of scraping warn! logs. The payload is a named ServerAck struct (same pattern as Receipt(Receipt), so it can grow without breaking matches) carrying id, class, from, the server t timestamp when present (whatsmeow reads the same attribute into SendResponse.Timestamp), and the nack error code. Server acks cover every outgoing stanza class, so class lets consumers filter message acks without correlating ids blind. The dispatch is gated on EventBus::has_handler_for(EventKind::ServerAck) so the hot ack path stays allocation-free when nobody subscribes (ack_miss_path_does_not_heap_allocate stays green). EventKind::ServerAck is appended at the end to keep existing EventInterest bit indexes stable; the build-time ceiling tripwire now points at it.
Summary
Follow-up to #975, taking two items from that PR's "Declined / follow-up" list as a focused refactor.
1. Cancellation-safe IQ response waiters — unblocks
try_join!(main change)send_and_wait_iqregisters an entry inresponse_waitersand only removed it on the send-fail / timeout / shutdown paths — never when the future itself is dropped mid-await (cancellation). So a fail-fastfutures::try_join!over two IQs drops the losing side the instant its sibling errors and leaks that waiter; a lingering waiter suppresses keepalives for the life of the connection. That's exactly why #975 had to keepis_on_whatsapponjoin!(await both) instead of the fail-fasttry_join!.Root-cause fix: an RAII
ResponseWaiterGuardthat removes the entry onDrop, covering every exit path including cancellation. To remove fromDrop(which can't.await),response_waitersmoves from anasync_lock::Mutexto astd::sync::Mutex— matching itsnode_waiterssibling (a trivial critical section never held across an await; the one drain site is block-scoped so the guard releases before its awaits). The four hand-rolledremove()calls insend_and_wait_iqcollapse into the guard.is_on_whatsappnow usestry_join!(fail-fast) — restoring the original sequential error-latency profile while keeping the two IQs concurrent.2. Name the bounded fan-out concurrency limits (quick win)
The five
buffer_unordered(16)sites (session probe, device-list resolve, status-LID resolve, companion-identity load, LID→PN resolve) get per-module named consts — matching the existingHISTORY_SYNC_CONCURRENCY/MEDIA_REUPLOAD_CONCURRENCY/APPSTATE_BLOB_DOWNLOAD_CONCURRENCYstyle so they're greppable and independently tunable.Why a sync mutex is safe here
Every
response_waiterslock site is a single-statement map op (insert / remove / len / is_empty), never held across an.await. Access is centralized through aClient::response_waiters_guard()helper (poison-recovering, mirroring thenode_waitershelpers) so no call site reaches for a bare.lock().unwrap(); the only direct lock left isResponseWaiterGuard::drop, which owns itsArc. The one site that binds the guard (cleanup_connection_state's drain) is block-scoped so it drops before the following awaits. Verified: the spawned client future staysSend, and wasm32--no-default-featuresbuilds.Review follow-ups (applied)
register_ack_waiter— its only await was theresponse_waiterslock; now a syncstd::sync::Mutexinsert, so it's a plainfn(.awaitdropped at all call sites).response_waiters_guard()helper — centralizes the poison-recovering lock; every&self/&Clientaccess routed through it.node_io— the IQ-response waiter removal is folded into the outer let-chain (matchinghandle_ack_response, per the repo's let-chain guideline).debug_assert!onreq_iduniqueness — the guard removes byreq_id; everysend_and_wait_iqcaller derivesreq_idfrom the monotonicgenerate_request_id()(verified across the codebase, incl. the soleInfoQuery.idsetter inpair_code.rs), so an id is never in flight twice and the remove-by-id is unambiguous. The assert documents/enforces that invariant and catches any future caller that breaks it (a per-waiter token would be dead weight for a structurally-unreachable hazard).Deliberately out of scope
place_callcancellation-safety — the ack/offer waiters were never cancellation-safe (pre-existing; this PR only migrated the lock syntax there, not the cleanup structure). Those paths also carry pending-call/registry state beyond the waiter, so extending the RAII pattern to them is a separate, larger change — a good follow-up now that the guard exists as a template.connection_generationthrough the task lifecycle; more delicate than quick. The existingprevious <= 1 → store(0)clamp already prevents the underflow; the remaining premature-idle edge is a separate, careful change.Testing
ResponseWaiterGuardremoves-on-drop, and is a no-op when the waiter was already resolved.test_ack_waiter_resolves(resolve path),relay_waiter_no_ack_removes_response_waiter(voip no-ack cleanup,--features voip), the keepalive suite.cargo clippy --lib --testsclean;cargo fmt --all; wasm32--no-default-featuresbuilds. Binary size ~11 KiB smaller (async→sync mutex + collapsed cleanup).