Skip to content

fix(client): release transport and feed guards before awaiting - #1221

Merged
jlucaso1 merged 2 commits into
mainfrom
fix/lock-guards-across-await
Aug 7, 2026
Merged

fix(client): release transport and feed guards before awaiting#1221
jlucaso1 merged 2 commits into
mainfrom
fix/lock-guards-across-await

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Edition 2024 shortened the life of an if let scrutinee temporary, but only for the else branch. In the arm that matches, the temporary is still alive for the whole body, because the binding may borrow from it. So in if let Some(x) = mutex.lock()....take() { x.something().await } the guard is held for the entire body, and a match scrutinee behaves the same way (the edition change never applied to it at all).

Four teardown paths in the client had exactly that shape: disconnect, reconnect, reconnect_immediately and cleanup_connection_state all closed the socket with the transport guard still held. That matters because WsTransport::disconnect writes a real TLS close frame with no timeout, and before that waits on the sink mutex the Noise sender task may be holding, while connect_internal installs the next connection's transport through that same transport mutex. A close that stalls therefore parks the entire reconnect, not just the socket it was closing. node_io's stream-error path already clones the Arc<dyn Transport> out of the guard before awaiting; these four now do the same.

cleanup_connection_state had one more instance of the same shape a few lines down: the app_state_processor guard was held across clear_key_cache().await, which takes the processor's own cache lock, so every concurrent get_app_state_processor() waited behind it.

In the video facade, attach_endpoints and detach_endpoints aborted the previous feed under the feed mutex. AbortHandle::abort invokes a Box<dyn FnOnce> the Runtime supplied, so what runs there is up to the runtime: one that cancels synchronously drops the task inline, and anything in that drop touching feed re-enters a non-reentrant std::sync::Mutex. This is preventive, not a live bug - TokioRuntime's abort closure is handle.abort(), which only sets a flag and returns. But the Runtime trait is public and consumers supply their own, so the hazard is reachable for a runtime the trait permits, which is why the test below has to bring its own runtime to demonstrate it.

Second commit, from review: releasing the transport guard removed an accidental barrier. While cleanup held it across the close, connect_internal could not publish a full connection in that window, because the first of its three installs blocked on the same mutex. Without the guard it can publish transport, event receiver and Noise socket while the old close is still in flight, and cleanup would then clear the replacement's transport_events and noise_socket rather than the ones it was tearing down, leaving the new connection with no read loop. Cleanup now retires all three slots in the same pass that takes the transport, before the close is awaited, so it only ever retires state that existed when it started.

Evidence

First, the language rule itself, since the whole batch hangs on it. A standalone edition 2024 binary, four shapes, printing whether the mutex is still locked from inside the body:

if-let  matching arm: guard held = true
after if-let:             guard held = false
if-let  else branch:      guard held = false
match   arm:              guard held = true
hoisted binding:          guard held = false

So the matching arm holds it, the else branch does not (that is the edition 2024 change), match holds it, and hoisting the value into a let before the if let releases it. That matches the premise this PR is built on.

Then the behavioral tests, all of which live in the PR as regressions. On the tree before the fix, with the tests applied:

test result: FAILED. 12 passed; 2 failed; 0 ignored; 0 measured

failures:
    client::lifecycle::tests::an_in_flight_socket_close_does_not_park_the_transport_slot
    voip::facade::tests::feed_swap_does_not_abort_while_holding_the_feed_lock

an_in_flight_socket_close_does_not_park_the_transport_slot installs a transport whose disconnect() parks until released, runs cleanup_connection_state until the close is confirmed in flight, and then does what connect_internal does: install the next transport into the slot. Before the fix that install never completes and the 5s timeout fires. feed_swap_does_not_abort_while_holding_the_feed_lock builds a client on a runtime whose abort closure re-enters feed.lock(), then runs attach / attach-replacing / detach on a dedicated thread so the re-entrant lock parks that thread instead of hanging the suite; the harness reports it via recv_timeout. Both use polling with a bounded timeout, no fixed sleeps.

The review finding got the same treatment. a_connection_published_during_cleanup_is_not_stripped_by_it publishes a replacement transport and event receiver while the old close is parked, then asserts both survive cleanup. With the clears left after the await it fails:

thread 'client::lifecycle::tests::a_connection_published_during_cleanup_is_not_stripped_by_it' panicked:
the replacement's event receiver must survive too, or its read loop never starts
test result: FAILED. 13 passed; 1 failed

After both fixes, everything in the crate:

test result: ok. 1576 passed; 0 failed; 1 ignored; 0 measured   (--features voip)
test result: ok. 1370 passed; 0 failed; 1 ignored; 0 measured   (default features)

No existing lifecycle or VoIP test needed adapting.

Changes

  • disconnect, reconnect, reconnect_immediately: clone the Arc<dyn Transport> out of the guard, then close. Same shape node_io already uses.
  • cleanup_connection_state: take the transport and clear transport_events and noise_socket in one pass, then close what it took. Ordering between shutdown -> disconnect -> cleanup is unchanged; this moves two lines within cleanup itself.
  • cleanup_connection_state_inner: clone the Arc<AppStateProcessor> out before clear_key_cache().await, so the processor slot is not held across the cache lock.
  • VideoShared::attach_endpoints / detach_endpoints: take the old AbortHandle out of the guard before abort().
  • AbortHandle::abort: take the closure out of abort_fn before calling it. Same class, and it sits directly in the abort path the facade fix is about, so an abort closure that ever touched its own handle would deadlock there instead.
  • No timeout, no retry, no new dependency. The rationale comment lives once per concern, not at all five sites.

Checked and not changed

The sweep was if let / match whose scrutinee produces a guard, plus the .write()/.read().await variants (none of those exist). What it turned up and why the rest is fine:

  • transports/tokio-transport/src/lib.rs:172 - disconnect() holds the sink guard while writing the close frame. Real, but not the same fix: the body consumes the value it took out, and releasing the guard early would flip a concurrent send() from blocking to failing fast with "Socket is closed". That is a behavior change in a different crate, so it does not belong in this batch. The client-side fix already stops it cascading into the reconnect.
  • wacore/src/appstate_sync.rs:163 - body is a bare return Ok(cached). No await, no call.
  • src/pair.rs:48 and :337 - bodies are tx.try_send(()), non-blocking by construction, plus a log line.
  • src/pair_code.rs:1564 and src/voip/transport.rs:515 - both inside #[cfg(test)], on locks that are not shared with anything, bodies trivial.
  • src/client/accessors.rs:323 and src/handlers/call.rs:125 - not the pattern. The first binds the guard in a block for a synchronous resource_report(); the second deliberately holds a lock for a scope.
  • wacore/src/voip/registry.rs - set_media_task (line 1522) has the same shape via match, and there are several if let sites in the same file. Left alone on purpose: that file is handled in a separate batch together with the lock-poison policy.

Validation

cargo fmt --all
cargo test -p whatsapp-rust --lib                    # 1370 passed
cargo test -p whatsapp-rust --lib --features voip    # 1576 passed
cargo clippy -p whatsapp-rust -p wacore --all-targets --features voip -- -D warnings   # clean

Workspace-wide clippy could not run here (alsa-sys has no system ALSA headers in this environment, which stops the voip-cli example before it reaches my crates), so the full matrix is left to CI. E2E not run locally; it is green on CI.

Edition 2024 shortened the life of an `if let` scrutinee temporary only for
the `else` branch. In the arm that matches, the temporary is still alive for
the whole body, because the binding may borrow from it. So in
`if let Some(x) = mutex.lock()....take() { x.something().await }` the guard is
held for the entire body, and the same is true of a `match` scrutinee.

That bites four teardown paths in the client: `disconnect`, `reconnect`,
`reconnect_immediately` and `cleanup_connection_state` all closed the socket
with the `transport` guard still held. `WsTransport::disconnect` writes a real
TLS close frame with no timeout, and first waits on the sink mutex the sender
task may be holding, while `connect_internal` installs the next connection's
transport through that same `transport` mutex. A close that stalls therefore
parks the whole reconnect. Hoisting the value out of the guard mirrors what
the stream-error path in `node_io` already does.

`cleanup_connection_state` had one more of the same shape: the
`app_state_processor` guard was held across `clear_key_cache().await`, which
takes the processor's own cache lock, so every `get_app_state_processor()`
waited behind it.

In the video facade, `attach_endpoints` and `detach_endpoints` aborted the
previous feed under the `feed` mutex. `AbortHandle::abort` runs a closure the
`Runtime` supplied; a runtime that cancels synchronously drops the task inline,
and anything in that drop touching `feed` re-enters a non-reentrant
`std::sync::Mutex`. Not reachable with the current tokio runtime, whose abort
is just a flag, but the trait admits runtimes where it is. `AbortHandle::abort`
itself called the closure under its own `abort_fn` lock for the same reason and
now takes it out first.

Both fixes come with a regression test that fails on the previous code: one
proves the transport slot stays installable while a close is in flight, one
drives attach/replace/detach against a runtime whose abort re-enters the feed
lock.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved connection recovery and cleanup by preventing socket shutdowns from blocking reconnection or other lifecycle operations.
    • Fixed potential deadlocks when replacing or detaching video endpoints during feed cancellation.
    • Improved cancellation handling to safely invoke abort callbacks without holding internal locks.
    • Added regression coverage for reconnect, cleanup, endpoint replacement, and detachment scenarios.

Walkthrough

The change updates asynchronous cleanup in client lifecycle and video endpoint handling. Mutex-protected handles are extracted before awaiting closure or invoking cancellation callbacks. Tests cover parked transport shutdown and reentrant video-feed cancellation.

Changes

Asynchronous cleanup

Layer / File(s) Summary
Abort callback extraction
wacore/src/runtime.rs
AbortHandle::abort extracts the callback before invoking it.
Video feed cancellation
src/voip/facade.rs
Video feed replacement and endpoint detachment release the feed mutex before aborting prior tasks. Tests cover synchronous re-entry during cancellation.
Transport lifecycle cleanup
src/client/lifecycle.rs
Disconnect, reconnect, and connection cleanup release mutexes before awaiting transport closure or key-cache clearing. Tests verify transport replacement and cleanup behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: greptile-apps

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description directly explains the lock-guard fixes, regression tests, affected components, and validation results.
Title check ✅ Passed The title clearly and concisely summarizes the main change: releasing client transport and feed guards before awaiting.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/lock-guards-across-await

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Aug 7, 2026

Copy link
Copy Markdown

Greptile Summary

The PR prevents teardown callbacks and asynchronous disconnect operations from running while their owning mutex guards remain held.

  • Extracts client transports and the app-state processor from guarded slots before awaiting teardown work.
  • Extracts VoIP feed handles before invoking runtime-defined cancellation.
  • Makes AbortHandle::abort invoke its callback after releasing the handle’s internal mutex.
  • Adds regression coverage for blocked transport closes, replacement connection state, and reentrant cancellation.

Confidence Score: 5/5

The PR appears safe to merge because no blocking failure remains within the eligible follow-up review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
src/client/lifecycle.rs Releases transport and app-state processor guards before asynchronous teardown and adds lifecycle race regression tests.
src/voip/facade.rs Moves feed cancellation outside the feed mutex and tests reentrant runtime cancellation.
wacore/src/runtime.rs Takes the one-shot abort callback under lock and invokes it only after releasing the internal mutex.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Acquire mutex] --> B[Take or clone guarded value]
  B --> C[Release mutex guard]
  C --> D[Await disconnect or invoke abort callback]
  C --> E[Concurrent caller may acquire mutex]
Loading

Reviews (2): Last reviewed commit: "fix(client): clear connection slots befo..." | Re-trigger Greptile

greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 7, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 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/client/lifecycle.rs`:
- Around line 1174-1176: Update the lifecycle cleanup method containing the
transport.take and disconnect call to prevent post-await cleanup from affecting
a replacement connection: retire or detach transport, transport_events, and
noise_socket before the first await, or guard each cleanup action with the
captured closed_generation. Extend the parked-close test to publish a
replacement transport_events and noise_socket and verify they remain intact.
🪄 Autofix

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: d60c4ab4-c624-4df4-b9d4-fedbda9fe4ec

📥 Commits

Reviewing files that changed from the base of the PR and between f8165f2 and 0377057.

📒 Files selected for processing (3)
  • src/client/lifecycle.rs
  • src/voip/facade.rs
  • wacore/src/runtime.rs

Comment thread src/client/lifecycle.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 03770574f8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client/lifecycle.rs
// idempotent, so the redundant call from `Client::disconnect()` is
// safe.
if let Some(transport) = self.transport.lock().await.take() {
let transport = self.transport.lock().await.take();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve replacement state after releasing the transport lock

When a new connect() progresses while this old transport's disconnect() is parked—the exact concurrency the new regression test permits—it can install its transport, event receiver, and Noise socket at lines 845–847. Once the old close resumes, this cleanup leaves the replacement transport in place but unconditionally clears the replacement's transport_events and noise_socket at lines 1178–1179, after which read_messages_loop() fails with NotStarted; the remaining cleanup also resets state belonging to that new connection. The teardown must either prevent publication until all old-generation cleanup is complete or conditionally clear only state associated with the transport/generation it took.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, and introduced by this change: the held guard used to block connect_internal's first install, so it could not publish a whole connection inside the close window. Fixed in 13ddd6f by clearing all three slots before the close is awaited, with a test that fails the other way round.


Generated by Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/client/lifecycle.rs
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 9.98 MiB 9.98 MiB 0
bin .text 8.00 MiB 8.00 MiB +320 B (+0.00%) 🔺
bin allocated (text+data+bss) 9.98 MiB 9.98 MiB +8 B (+0.00%) 🔺
llvm-lines wacore 513,103 513,099 -4 (-0.00%) 🔽
llvm-lines wacore copies 16,751 16,751 0
llvm-lines whatsapp-rust lib 736,307 736,388 +81 (+0.01%) 🔺
llvm-lines whatsapp-rust lib copies 23,173 23,173 0
deps crates (Cargo.lock) 462 462 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.83 MiB 1.83 MiB -10 B (-0.00%) 🔽
.text wacore 686.39 KiB 686.71 KiB +336 B (+0.05%) 🔺
.text wacore_binary 88.60 KiB 88.60 KiB 0
.text wacore_libsignal 173.44 KiB 173.44 KiB 0
.text wacore_appstate 22.35 KiB 22.35 KiB 0
.text wacore_noise 20.94 KiB 20.94 KiB 0
.text waproto 1.74 MiB 1.74 MiB 0
.text whatsapp_rust_sqlite_storage 515.62 KiB 515.62 KiB 0
.text whatsapp_rust_tokio_transport 40.49 KiB 40.49 KiB 0
.text whatsapp_rust_ureq_http_client 11.83 KiB 11.83 KiB 0
.text std 988.88 KiB 988.96 KiB +82 B (+0.01%) 🔺
.text other deps 1.90 MiB 1.90 MiB -26 B (-0.00%) 🔽

Baseline: f8165f282 (latest main run) · Head: 7e1376d59 · Graphs

Releasing the transport guard removed an accidental barrier: while cleanup
held it across the close, a concurrent `connect()` could not finish publishing
its transport, event receiver and Noise socket, because the first of those
three installs blocked on the same mutex. Without the guard it can publish all
three while the old close is still in flight, and cleanup then cleared the
replacement's `transport_events` and `noise_socket` instead of the ones it was
tearing down, leaving the new connection with no read loop.

Clear all three slots in the same pass that takes the transport, before the
close is awaited, so cleanup only ever retires state that existed when it
started. Ordering between shutdown, disconnect and cleanup is unchanged; this
moves two lines within cleanup itself.
@greptile-apps
greptile-apps Bot dismissed their stale review August 7, 2026 07:15

Dismissed because a newer commit was pushed; Greptile will re-review the current head.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 13ddd6f333

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/client/lifecycle.rs
Comment on lines +1178 to 1180
let transport = self.transport.lock().await.take();
*self.transport_events.lock().await = None;
*self.noise_socket.lock().await = None;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Serialize connection-slot publication with teardown

Fresh evidence after the prior comment: moving these clears before disconnect().await still leaves the handoff racy because the three slots use separate mutex acquisitions. If connect_graph() publishes its replacement transport and event receiver at lines 845–847 after this take() but before the following assignments, cleanup can erase the replacement receiver or Noise socket; connect() then returns with is_connected == true, while read_messages_loop() fails with NotStarted. Serialize publication and teardown as one operation, or condition each clear on the connection generation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The narrow window between the three acquisitions is real, but it is not something this PR created or widened: before it, the same window sat between the guard drop and the two clears. What the PR did widen was the whole-close window, and that one is gone.

Neither remedy is a small change here. The generation check does not work as written: connect_internal does not stamp a generation when it publishes at 845-847, and cleanup itself bumps connection_generation on entry (line 1121), so both connections carry the same value at that point and the clears cannot tell them apart. Serializing publication with teardown means one mutex over all three slots, which is a restructuring of the connect path.

It would also not be sufficient. Cleanup goes on after the close to reset media_conn, the app-state key cache and request map, the chat lanes and the receipt state, none of which is generation-scoped either, so a connect() genuinely concurrent with a teardown is unsafe well beyond these three slots. In the real call graph it does not happen: the run loop awaits cleanup before connecting, and disconnect() clears is_running first. Making that interleaving safe is a teardown-generation change worth its own PR, not a rider on a guard fix.


Generated by Claude Code

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Confidence score: 4/5

  • In src/client/lifecycle.rs, the mid-close publish window is improved, but clearing the three slots via separate lock acquisitions can still race with connect_internal, leaving partially reset lifecycle state and causing intermittent reconnect/close misbehavior — clear all three fields under one lock scope (or one atomic state transition) to remove the interleaving risk.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/client/lifecycle.rs">

<violation number="1" location="src/client/lifecycle.rs:1179">
P2: Release of the transport guard is the right fix and closes the long mid-close publish window, but the three slots are still cleared in three separate non-atomic lock acquisitions. If a racing `connect_internal` (the exact concurrency this PR enables) publishes its new transport, transport_events and noise_socket *between* the `take()` on line 1178 and these two `= None` clears, its freshly published events and noise_socket get stripped even though its transport survives — tearing down a connection it doesn't own. The existing regression test only publishes after the close is entered (i.e. after these clears have already run), so it never exercises this narrower interleaving. Consider hardening the publish/teardown handoff (e.g. a generation check) or at least covering this interleaving deterministically.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/client/lifecycle.rs
// publish its own transport, events and socket while the close is in flight; clearing
// afterwards would strip the replacement connection instead of the one being torn down.
let transport = self.transport.lock().await.take();
*self.transport_events.lock().await = None;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Release of the transport guard is the right fix and closes the long mid-close publish window, but the three slots are still cleared in three separate non-atomic lock acquisitions. If a racing connect_internal (the exact concurrency this PR enables) publishes its new transport, transport_events and noise_socket between the take() on line 1178 and these two = None clears, its freshly published events and noise_socket get stripped even though its transport survives — tearing down a connection it doesn't own. The existing regression test only publishes after the close is entered (i.e. after these clears have already run), so it never exercises this narrower interleaving. Consider hardening the publish/teardown handoff (e.g. a generation check) or at least covering this interleaving deterministically.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/client/lifecycle.rs, line 1179:

<comment>Release of the transport guard is the right fix and closes the long mid-close publish window, but the three slots are still cleared in three separate non-atomic lock acquisitions. If a racing `connect_internal` (the exact concurrency this PR enables) publishes its new transport, transport_events and noise_socket *between* the `take()` on line 1178 and these two `= None` clears, its freshly published events and noise_socket get stripped even though its transport survives — tearing down a connection it doesn't own. The existing regression test only publishes after the close is entered (i.e. after these clears have already run), so it never exercises this narrower interleaving. Consider hardening the publish/teardown handoff (e.g. a generation check) or at least covering this interleaving deterministically.</comment>

<file context>
@@ -1171,12 +1171,16 @@ impl Client {
+        // publish its own transport, events and socket while the close is in flight; clearing
+        // afterwards would strip the replacement connection instead of the one being torn down.
         let transport = self.transport.lock().await.take();
+        *self.transport_events.lock().await = None;
+        *self.noise_socket.lock().await = None;
         if let Some(transport) = transport {
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered in the parallel thread on line 1180. Short version: that narrow window predates this PR (it sat between the guard drop and the clears), a generation check cannot work until connect_internal stamps one at publish time, and cleanup keeps resetting other connection-scoped state after the close anyway. Worth its own change, not a rider here.


Generated by Claude Code

@jlucaso1
jlucaso1 merged commit f79d29c into main Aug 7, 2026
42 checks passed
@jlucaso1
jlucaso1 deleted the fix/lock-guards-across-await branch August 7, 2026 15:08
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.

2 participants