Skip to content

fix(client): make it plain which call reads the socket - #1258

Merged
jlucaso1 merged 7 commits into
mainfrom
fix/connect-run-read-loop-contract
Aug 8, 2026
Merged

fix(client): make it plain which call reads the socket#1258
jlucaso1 merged 7 commits into
mainfrom
fix/connect-run-read-loop-contract

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

A consumer that calls connect().await and then waits for events waits forever: the handshake completes, the transport receives bytes, and nothing decodes them, because read_messages_loop runs inside run, not inside connect. The transport channel buffers, so the frames just sit there with no reader; there is no timeout, no error and no log that says so, which is why this reads as a broken server and costs hours to pin down. Documenting it is not enough for a bug whose only symptom is silence, so connect now returns a #[must_use] Connection that has to be driven with read_until_disconnected, and the compiler reports the mistake at the call site instead. Driving one connection is exactly what run's loop body already did (read loop, teardown, Disconnected dispatch), now extracted and shared with it, so the direct path and the reconnect loop cannot drift on what a connection ending means. Connecting without the reconnect loop stays supported and finally has a way to be read.

Audit

Each of these was checked against the tree before designing anything:

  • connect had a doc comment, but it only explained the boxed future (src/client/lifecycle.rs:707-710 before this change). Nothing said the established connection is not read.
  • run had no item doc at all (src/client/lifecycle.rs:552), only the internal note about deliberately not instrumenting its span.
  • read_messages_loop is pub(crate) (src/client/node_io.rs:177) and is called from exactly one place, run's loop body (src/client/lifecycle.rs:598). A consumer could neither call it nor discover it from the public API.
  • Nothing denounces the situation at runtime. The events channel is a bounded async_channel filled by the transport task, and its only consumer is the read loop, so the frames simply queue. The keepalive loop was spawned by connect, but its ping goes through can_reach_server, which requires is_running and so refuses on a client that never started a reader: even the dead-socket watchdog never fires.
  • No example, README snippet or agent_docs/ page uses connect without run. Every entry point in the tree goes through Bot::run/Bot::spawn (README.md:57, examples/*.rs, tests/e2e/src/lib.rs), which call Client::run (src/bot.rs:546, src/bot.rs:557). The only in-tree callers of Client::connect are run itself and two tests (src/client/builder.rs:800, src/client/lifecycle.rs:1515), so nothing depended on connecting and not reading.
  • The concept of a client with no supervision loop is real and documented in the code (is_terminal and can_reach_server both reason about it), so the design had to keep it working rather than make connect private.

Making that concept a first-class path turned up three things the audit had not, each harmless only while nothing read a directly established connection:

  • expected_disconnect was cleared only at the top of run's loop (src/client/lifecycle.rs:583), so a caller driving connections itself carried the retired connection's verdict into the next one.
  • connect had no shutdown check, unlike run, so a connection established after disconnect was one the application had already been told did not exist. One check is also not enough: a version fetch, a transport, a handshake and the slot locks are awaited after it.
  • The keepalive was spawned at the end of connect, so a tick taken before anything read the connection was refused as NotConnected, which its loop classifies as fatal and exits on.

Changes

  • Breaking: Client::connect resolves to Result<Connection<'_>, ConnectError> instead of Result<(), ConnectError>. Connection is #[must_use], so the old client.connect().await?; becomes an unused_must_use rather than silently doing nothing. Migration: client.connect().await?.read_until_disconnected().await;, or client.run().await for a session that reconnects. A compile_fail doctest on connect pins the old form as no longer accepted, next to a no_run twin for the new one.
  • Connection::read_until_disconnected is the public single-connection read: read loop, connection teardown, Disconnected dispatch, returning the disconnect reason (None when there was no failure to report). No reconnect, so a session that must not outlive its first connection now has a supported route.
  • run's per-connection half is extracted into Client::drive_connection and shared with the above. Pure extraction: same order, same logs, same event, and run keeps telemetry::connect("ok") at the same point.
  • connect clears expected_disconnect with the rest of the per-connection state. Only run cleared it before, so a connection driven directly after an end that sets the flag (the 515 that follows pairing) came up with it still set: handle_success refuses the login and the read loop exits on the first frame it decodes. run clears it before it connects, so its path is unchanged.
  • Breaking (additive): connect refuses a shut-down client with the new ConnectError::Shutdown, the same refusal run already makes. It reads the signal again before the handshake, and once more after the slots are filled, undoing the publish if a shutdown landed across those awaits: signal_shutdown_sync runs no cleanup of its own, so a socket installed there would outlive the client that announced it was gone. run reports the abandonment at debug rather than error, since it is about to exit on the same shutdown. ConnectError is #[non_exhaustive], so the new variant only affects code matching it exhaustively.
  • The keepalive starts where the reading starts rather than at the end of connect, so it cannot take a tick before a reader exists and exit as fatal. For run that is the same instant; for a directly driven connection it is the difference between having a dead-socket watchdog and not.
  • A connection driven directly claims is_running for its duration and gives it back through a scope guard, so a dropped read (a wrapping timeout, an aborted task) does not leave the flag standing. That flag is already the client's answer to "is anything reading" (can_reach_server, is_terminal); leaving it false would refuse every request sent over a connection that does have a reader, and leaving it stuck true would advertise a reader that is gone. run sets it before connecting, so its path is untouched by this.
  • A Connection dropped without being read logs a warning. That is the answer to the "nobody reads for N seconds" question: there is no N. Drop is the moment we know for certain, it costs no timer and no task, and nothing legitimate connects and then never reads.
  • Item docs on run and connect stating which one reads, plus corrections to the is_terminal, can_reach_server and stop_supervision_loop docs, which described is_running as "run()'s supervision loop" and now have a second reader to account for.
  • No runtime cost on the happy path: Connection borrows the client, so size_of::<Result<Connection, ConnectError>>() == size_of::<Result<(), ConnectError>>() (asserted in a test) and connect allocates exactly what it did before. The added shutdown reads are loads on a sticky flag, and the retraction allocates nothing on the path that succeeds.

Four things the AI reviewers raised that this deliberately does not do:

  • Teardown on cancellation. An abandoned read_until_disconnected does not run cleanup_connection_state, so the socket stays open and the client keeps reporting itself connected until disconnect() releases it. That is not new and not specific to this path: it is what dropping the run future has always done, and cleanup_connection_state is async, so no drop guard can await it. Spawning a detached teardown instead would be worse than the state it fixes: cleanup only clears the transport slots before the close it awaits, which is safe today because every caller awaits cleanup before connecting again, and a detached one could strip a connection the consumer establishes right after. The reader flag is restored because that one is a synchronous store, and the rest of the contract is documented on the method.
  • A shared gate serializing the last shutdown read with is_connected.store(true). What remains after the retraction above is a window with no awaits in it, and losing it does not strand anything: the shutdown signal is sticky (wacore/src/runtime.rs:252-273), and both disconnect and signal_shutdown_sync fire notify_connection_shutdown. A shutdown before the connection's notifier is reset is caught by the retraction; one after it leaves the fresh notifier fired, so the read loop returns Expected on its first poll and drive_connection tears the connection down in full. Closing that window properly would mean a new cross-cutting mutex on the connect path that every terminal path must take, including signal_shutdown_sync, which exists precisely to be callable from Drop where nothing can be awaited or blocked on. That is a worse trade than the self-healing state it replaces.
  • Refusing a second reader when is_running is set. As proposed this would break run, which sets is_running before it connects and then drives through the same call. Two concurrent readers are also not reachable: a Connection only exists after is_connected is true, and connect refuses with AlreadyConnected from that point on. A hypothetical second reader would in any case fail immediately, because read_messages_loop takes the transport-event receiver out of its slot.
  • A distinct outcome for a protocol-driven reconnect. A 515 ends the connection with no failure to report, so it returns None like a requested end does. Telling the two apart would mean a new public outcome type for a distinction a caller acts on the same way (connect again), so this documents what None covers instead.

Tests, all in src/client/lifecycle.rs:

  • a_driven_connection_decodes_the_first_frame_and_reports_its_end is the regression: a frame is queued on a connection, the recommended path is driven, and the assertion is on the decoded node (an Event::RawNode) and on the returned disconnect reason, not on a log.
  • a_connection_nobody_reads_leaves_the_frame_queued pins what connecting alone guarantees (the socket is up) and what it does not (the frame is still queued, no node decoded, the reader's inlet untouched). It is the reported symptom, turned into a test.
  • a_cancelled_read_gives_the_reader_flag_back covers the failure side of claiming is_running: the read is cancelled mid-connection and the flag must come back.
  • connecting_clears_the_retired_connection_expected_disconnect covers the inherited-verdict fix; it fails without it.
  • connecting_is_refused_after_the_client_is_shut_down covers the shutdown refusal at the entry point. The reads inside the connect graph are not covered by tests of their own, and neither is the interleaving the reviewers asked for: reaching them needs a handshake to complete, which no unit fixture can do without a mock server.
  • handing_back_a_connection_costs_the_caller_nothing asserts the size equality above.
  • The 1444 existing lib tests pass unchanged. The one that could have needed adapting, connect_rejects_an_already_connected_client, uses expect_err and so requires Debug on the Ok type; Connection implements it rather than the test changing.

Validation

cargo fmt --all
cargo test -p whatsapp-rust --lib          # 1447 passed
cargo test -p whatsapp-rust --doc connect  # both doctests, including the compile_fail migration one
cargo clippy -p whatsapp-rust --all-targets -- -D warnings
cargo check -p whatsapp-rust --all-features --all-targets

Both new guarantees were checked against their own absence: with #[must_use] removed the migration doctest reports "Test compiled successfully, but it's marked compile_fail", and with the expected_disconnect reset removed its test fails. The full workspace clippy could not run locally (the voip-cli example needs libasound, absent in this environment); full matrix left to CI.

`Client::connect` established the connection and returned `()`. Nothing read
it: `read_messages_loop` runs inside `run`, so a consumer that connected and
then waited for events waited on a socket whose frames sat in the transport
channel with no reader. The failure mode is silence, because the channel
buffers: no timeout, no error and no log to follow. The docs pointed nowhere
either. `connect`'s doc covered only the boxing of its future, and `run` had
no item doc at all.

`connect` now hands back a `#[must_use]` `Connection` that has to be driven
with `read_until_disconnected`, so the compiler says at the call site what
used to cost hours of tracing. Driving it reads exactly one connection: the
read loop, the teardown and the `Disconnected` dispatch that `run`'s loop body
already performed, now shared with it so the two cannot drift. Connecting
without a reconnect loop stays possible and gains a reader of its own, which
claims `is_running` for its duration because that flag is how the rest of the
client asks whether anything is reading. A connection dropped undriven warns,
since nothing legitimate does that.

The connection borrows the client, so the connect path keeps its size and its
allocations.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Connections now provide an explicit handle for reading until disconnection.
    • Connection status and disconnection reasons are surfaced more clearly, including terminal shutdown.
    • Connection handling supports direct control alongside automatic supervision and reconnection.
    • Connection functionality is available through the public client interface.
  • Bug Fixes

    • Improved cleanup during cancellation, shutdown, and reconnect races.
    • Prevented unread connections from being left unmanaged and added warnings when applicable.
    • Improved handling of queued messages and transport replacement during cleanup.
    • Improved detection of server reachability during active connections.

Walkthrough

Client::connect now returns a must-use Connection after the handshake. Callers explicitly read until disconnection. Client::run uses the same path with centralized teardown, disconnect events, cancellation-safe reader ownership, and reconnect handling.

Changes

Connection lifecycle

Layer / File(s) Summary
Connection API contract
src/client.rs, src/client/lifecycle.rs, src/lib.rs
Client::connect and connect_graph return Connection handles after the handshake. ConnectError::Shutdown represents terminal shutdown. The handle is publicly re-exported.
Shared connection supervision
src/client/lifecycle.rs
Client::run reads through Connection::read_until_disconnected. Shared logic performs keepalive startup, teardown, disconnect event dispatch, stale-state reset, and reconnect handling.
Reader ownership and validation
src/client/lifecycle.rs
Connection manages reader ownership, cancellation, unread drops, frame decoding, queued frames, disconnect reasons, shutdown behavior, and result layout size. Tests cover these behaviors.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested labels: api-design, breaking-change

Suggested reviewers: greptile-apps

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Connection
  participant Transport
  participant EventDispatcher
  Client->>Transport: complete handshake
  Transport-->>Client: established transport
  Client-->>Connection: return connection handle
  Client->>Connection: read_until_disconnected
  Connection->>Transport: read and decode frames
  Transport-->>Connection: disconnect reason
  Connection->>EventDispatcher: dispatch Disconnected event
  Connection-->>Client: return disconnect result
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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 clearly explains the connection-reading API change, its behavior, migration path, tests, and validation.
Title check ✅ Passed The title clearly identifies the main change: making the API call responsible for reading the socket explicit.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/connect-run-read-loop-contract

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 8, 2026

Copy link
Copy Markdown

Greptile Summary

The PR makes the socket-reading responsibility explicit by returning a must-use Connection from Client::connect and sharing single-connection driving and teardown with Client::run.

  • Adds Connection::read_until_disconnected for directly managed sessions.
  • Moves keepalive startup to the point where socket reading begins.
  • Rejects connections after terminal shutdown and retracts connection resources if shutdown races setup.
  • Resets per-connection disconnect state and updates lifecycle documentation and tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/client/lifecycle.rs Introduces the must-use connection handle, shared connection-driving lifecycle, shutdown-race retraction, keepalive relocation, and regression coverage without leaving an eligible follow-up defect.
src/client.rs Exports Connection and adds the non-exhaustive shutdown connection error.
src/lib.rs Re-exports the new public Connection type from the crate root and prelude.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Client::connect] --> B[Version fetch and transport setup]
  B --> C[Noise handshake]
  C --> D{Shutdown observed?}
  D -- Yes --> E[Retract transport resources]
  E --> F[Return ConnectError::Shutdown]
  D -- No --> G[Return must-use Connection]
  G --> H[Connection::read_until_disconnected]
  H --> I[Start keepalive]
  I --> J[Read and decode frames]
  J --> K[Clean up connection state]
  K --> L[Dispatch unexpected disconnect]
  M[Client::run] --> A
  L --> M
Loading

Reviews (7): Last reviewed commit: "fix(client): retract a connection publis..." | Re-trigger Greptile

Comment thread src/client/lifecycle.rs Outdated

@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
`read_until_disconnected` cleared `is_running` after its await, so a caller
that dropped the future (a wrapping timeout, an aborted task) left the flag
standing. The client would then advertise a reader that no longer exists:
`run()` refuses to start as already running, and `can_reach_server` admits
requests nothing will ever decode the answers to. A scope guard restores it on
every exit, cancellation included.
Comment thread src/client/lifecycle.rs

@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: 3/5

  • In src/client/lifecycle.rs, drive_connection().await lacks a cancellation guard, so a cancelled directly-driven read can skip cleanup_connection and leave lifecycle state/resources partially torn down, which risks stale connection behavior on subsequent operations — add a guard that guarantees teardown runs on cancellation (not just is_running restoration).
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:1520">
P2: The guard correctly restores `is_running` when a directly-driven read is cancelled, but it only restores that one flag. `drive_connection().await` is awaited without a cancellation guard, so its teardown (`cleanup_connection_state`, which clears `is_connected` and releases the transport/socket) never runs on the drop/cancel path — the exact path this change targets. Since `Connection` is consumed by value, a cancelled read cannot be re-driven, and the next `connect()` returns `AlreadyConnected` because `is_connected` is still true, leaving `run()` looping on that error and the socket leaked. Consider wrapping the drive so teardown also happens when the read is abandoned (e.g. hoisting the cleanup/flag restoration into a `scopeguard` around the whole await, or calling `cleanup_connection_state` from a drop-based guard so both `is_running` and connection state are released together).</violation>
</file>

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

Re-trigger cubic

Comment thread src/client/lifecycle.rs
this.client.stop_supervision_loop();
})
});
this.client.drive_connection().await

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: The guard correctly restores is_running when a directly-driven read is cancelled, but it only restores that one flag. drive_connection().await is awaited without a cancellation guard, so its teardown (cleanup_connection_state, which clears is_connected and releases the transport/socket) never runs on the drop/cancel path — the exact path this change targets. Since Connection is consumed by value, a cancelled read cannot be re-driven, and the next connect() returns AlreadyConnected because is_connected is still true, leaving run() looping on that error and the socket leaked. Consider wrapping the drive so teardown also happens when the read is abandoned (e.g. hoisting the cleanup/flag restoration into a scopeguard around the whole await, or calling cleanup_connection_state from a drop-based guard so both is_running and connection state are released together).

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 1520:

<comment>The guard correctly restores `is_running` when a directly-driven read is cancelled, but it only restores that one flag. `drive_connection().await` is awaited without a cancellation guard, so its teardown (`cleanup_connection_state`, which clears `is_connected` and releases the transport/socket) never runs on the drop/cancel path — the exact path this change targets. Since `Connection` is consumed by value, a cancelled read cannot be re-driven, and the next `connect()` returns `AlreadyConnected` because `is_connected` is still true, leaving `run()` looping on that error and the socket leaked. Consider wrapping the drive so teardown also happens when the read is abandoned (e.g. hoisting the cleanup/flag restoration into a `scopeguard` around the whole await, or calling `cleanup_connection_state` from a drop-based guard so both `is_running` and connection state are released together).</comment>

<file context>
@@ -1508,12 +1508,16 @@ impl Connection<'_> {
+                this.client.stop_supervision_loop();
+            })
+        });
+        this.client.drive_connection().await
     }
 }
</file context>

The teardown lives after an await, so an abandoned read leaves the socket
open and the client connected, the same way an abandoned `run` does. Only
the reader flag comes back on its own; state the rest instead of letting a
caller find out.

@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 1508-1527: Preserve the single-reader contract in
read_until_disconnected by refusing to call drive_connection when
client.is_running is already set. Make the ownership claim atomic and return the
existing failure result for a second reader; only the caller that successfully
claims the reader may install the release guard and drive the connection.
🪄 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: 8c35f747-6daf-4e8a-ad72-606b158d2316

📥 Commits

Reviewing files that changed from the base of the PR and between 4b34ca5 and bbbb38c.

📒 Files selected for processing (3)
  • src/client.rs
  • src/client/lifecycle.rs
  • src/lib.rs

Comment thread src/client/lifecycle.rs
Comment on lines +1508 to +1527
pub async fn read_until_disconnected(self) -> Option<DisconnectReason> {
// Reading is what the Drop warning asks for, so the drop that ends this
// call must not fire it.
let this = std::mem::ManuallyDrop::new(self);
// `is_running` is how the rest of the client asks whether anything is
// reading (`can_reach_server`, `is_terminal`). Left false, a request
// sent over this connection would be refused for want of the reader
// that is right here. `run` sets it before connecting and owns clearing
// it, so only a directly driven connection claims it.
// Guarded, not a store after the await: a caller that drops this future
// (a wrapping timeout, an aborted task) stops reading too, and a flag
// left standing would advertise a reader that no longer exists.
let owns_reader = !this.client.is_running.swap(true, Ordering::SeqCst);
let _release_reader = owns_reader.then(|| {
scopeguard::guard((), |_| {
this.client.stop_supervision_loop();
})
});
this.client.drive_connection().await
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find every caller of read_until_disconnected and drive_connection, plus every is_running mutation.
set -euo pipefail

rg -nP -C4 '\bread_until_disconnected\s*\(' --type=rust
echo '--- drive_connection ---'
rg -nP -C4 '\bdrive_connection\s*\(' --type=rust
echo '--- is_running writes ---'
rg -nP -C3 'is_running\s*\.\s*(store|swap|compare_exchange)' --type=rust

Repository: oxidezap/whatsapp-rust

Length of output: 160


🏁 Script executed:

#!/bin/bash
set -u
echo "Repo files matching lifecycle.rs:"
git ls-files | grep 'lifecycle\.rs$' || true
echo "Top-level files:"
git ls-files | head -50

Repository: oxidezap/whatsapp-rust

Length of output: 1529


🏁 Script executed:

#!/bin/bash
set -u
echo "Current directory:"
pwd
echo "Git status:"
git status --short
echo "Find lifecycle.rs:"
find . -type f -name lifecycle.rs 2>/dev/null
echo "Find DriveConnection/read_until symbols in tracked files:"
grep -RInE "read_until_disconnected|drive_connection|is_running|already_connected|AlreadyConnected" . --include='*.rs' 2>/dev/null | head -200

Repository: oxidezap/whatsapp-rust

Length of output: 7674


🏁 Script executed:

#!/bin/bash
set -u
echo "Current directory:"
pwd
echo "Git status:"
git status --short
echo "Find lifecycle.rs:"
find . -type f -name lifecycle.rs 2>/dev/null
echo "Find symbols in tracked files:"
grep -RInE "read_until_disconnected|drive_connection|is_running|already_connected|AlreadyConnected" . --include='*.rs' 2>/dev/null | head -200

Repository: oxidezap/whatsapp-rust

Length of output: 7647


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "src/client/lifecycle.rs Connection/read_until_disconnected/drive_connection lines:"
sed -n '100,115p;1448,1540p' src/client/lifecycle.rs
echo "--- run/connect context ---"
sed -n '550,620p;720,990p' src/client/lifecycle.rs
echo "--- client connect/AlreadyConnected ---"
sed -n '680,830p' src/client.rs

python3 - <<'PY'
from pathlib import Path
src = Path("src/client/lifecycle.rs").read_text()
# Read the exact read_until_disconnected body as non-mutative evidence.
for i, line in enumerate(src.splitlines(), 1):
    if "pub async fn read_until_disconnected(self)" in line:
        end = i + 30
        print(f"--- first 22 lines after read_until_disconnected ---")
        for j in range(i, min(end, len(src.splitlines())+1)):
            print(f"{j}: {src.splitlines()[j-1]}")
PY

Repository: oxidezap/whatsapp-rust

Length of output: 29395


Refuse a second reader or keep the single-reader contract.

read_until_disconnected calls drive_connection() even when is_running is already true, and run() drives drive_connection() while is_running is true. If connect_graph() returns a Connection, another caller can drive the same link in parallel. That can clear shared connection state and dispatch Disconnected from two readers. Keep Connection exclusive by rejecting the reader when is_running is set, or change the design so multiple readers are explicit.

🤖 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/lifecycle.rs` around lines 1508 - 1527, Preserve the single-reader
contract in read_until_disconnected by refusing to call drive_connection when
client.is_running is already set. Make the ownership claim atomic and return the
existing failure result for a second reader; only the caller that successfully
claims the reader may install the release guard and drive the connection.

@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.

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

@jlucaso1

jlucaso1 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

@codex review

@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: bbbb38cec3

ℹ️ 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 +1521 to +1524
let _release_reader = owns_reader.then(|| {
scopeguard::guard((), |_| {
this.client.stop_supervision_loop();
})

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 Clean up the connection when a direct read is cancelled

When a timeout or task abort drops read_until_disconnected after read_messages_loop has taken and dropped transport_events, this guard only clears is_running; it leaves is_connected and the transport slots populated. A subsequent run() therefore passes its reader check but connect_graph() returns AlreadyConnected on every retry, while no receiver remains to process frames, so the client cannot recover without an explicit disconnect(). Cancellation needs to trigger connection teardown (or otherwise make the stale connection reconnectable), rather than merely releasing the reader flag.

Useful? React with 👍 / 👎.

Comment thread src/client/lifecycle.rs
Comment on lines +742 to +745
Ok(node_io::ReadLoopExit::Expected) => {
debug!("Message loop exited gracefully (expected disconnect).");
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 Badge Preserve the reconnect outcome for direct readers

When a directly driven fresh-pairing connection receives the protocol's 515, the handler sets expected_disconnect and wakes the read loop, which reaches this Expected branch. Returning None makes that server-required reconnect indistinguishable from a caller-requested shutdown, even though run() relies on the still-private expected_disconnect flag to reconnect immediately; a consumer of the new public single-connection API therefore cannot tell that pairing needs another connection and may stop with pairing/bootstrap incomplete. Return a distinct expected-reconnect outcome (or otherwise expose it) instead of reporting every expected exit as a requested end.

Useful? React with 👍 / 👎.

Comment thread src/client/lifecycle.rs
this.client.stop_supervision_loop();
})
});
this.client.drive_connection().await

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 Reset the expected-disconnect state for the next direct read

After a directly driven connection ends through an internally expected transition such as the post-pairing 515, drive_connection cleans up but leaves expected_disconnect set; only run() clears it at the top of its loop. Even if a direct-API consumer independently knows to call connect() again, the replacement connection inherits that flag, so handle_success rejects its login and the read loop exits on its first decoded frame. Reset or consume the retired connection's flag before driving a subsequently established direct connection.

Useful? React with 👍 / 👎.

`expected_disconnect` is the previous connection's verdict, and only `run`
cleared it, at the top of each attempt. A caller that drives connections
itself had nowhere to learn of it: after an end that sets the flag (the 515
that follows pairing), its next connection came up with the flag still set,
so `handle_success` refused the login and the read loop exited on the first
frame it decoded. Clear it with the other per-connection state in the connect
graph, where both callers pass.

Also states on `read_until_disconnected` what `None` covers, since a
protocol-driven end like that 515 reports no reason either, and what an
abandoned read leaves behind.
greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 8, 2026

@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.

0 issues found across 1 file (changes from recent commits).

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Requires human review: Auto-approval blocked by 1 unresolved issue from previous reviews.

Re-trigger cubic

@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: 5494a0c7cc

ℹ️ 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
// the read loop exit on the first frame it decodes and `handle_success`
// refuse the login. `run` already clears it before each attempt; a
// caller driving connections itself has nowhere else to learn of it.
self.expected_disconnect.store(false, Ordering::Relaxed);

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 Badge Refuse connections after terminal shutdown

When connect() is called after disconnect()/signal_shutdown_sync()—or races just after either sets the terminal flags—this store erases expected_disconnect even though the one-shot shutdown notifier remains fired. The handshake later resets the per-connection shutdown signal, and a directly driven Connection can process frames and satisfy can_reach_server(), effectively resurrecting a client whose terminal shutdown has already been published. Check the terminal shutdown state and reject the attempt rather than clearing its verdict.

Useful? React with 👍 / 👎.

Comment thread src/client/lifecycle.rs
.detach();

Ok(())
Ok(Connection { client: self })

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 Badge Keep keepalive alive until the direct reader starts

If a caller holds the returned Connection for at least the first 15–30 second keepalive interval before polling read_until_disconnected, the keepalive task spawned just above calls send_iq while is_running is still false. send_and_wait_iq returns NotConnected, which is classified as fatal, so the keepalive loop exits permanently even after the caller starts reading; that connection then has no idle ping or dead-socket watchdog. Start keepalive when the connection is driven, or otherwise prevent the pre-reader tick from terminating it.

Useful? React with 👍 / 👎.

…with the reader

Two holes the direct-connection path opened, both invisible until something
actually reads that connection.

Shutdown is published once and for good, and `run` already refuses to start
after it. `connect` did not, so a connection driven after `disconnect` came
up on a client whose application had been told it was finished. It now
refuses with `ConnectError::Shutdown`.

The keepalive was spawned at the end of connect, before anything reads. Its
ping goes through `can_reach_server`, so a tick taken in that window is
refused as NotConnected, which the loop treats as fatal and exits on: the
connection then runs with no idle ping and no dead-socket watchdog. It starts
where the reading starts instead, which for `run` is the same instant.
@greptile-apps
greptile-apps Bot dismissed their stale review August 8, 2026 05:07

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

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

@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 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

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: 3f9f1ea0c1

ℹ️ 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 +718 to +719
if self.shutdown_signal().is_fired() {
return Err(ConnectError::Shutdown);

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 Badge Recheck shutdown before publishing the transport

When disconnect() races with an in-flight connect(), shutdown can fire immediately after this one-time check while connect_graph is awaiting version resolution, transport creation, or the handshake. Fresh evidence in the final code is that connect_graph subsequently clears expected_disconnect, resets the per-connection shutdown notifier, and can publish its transport after disconnect() has already completed cleanup, returning a live Connection from a terminally shut-down client. Recheck shutdown within the serialized attempt before publishing the socket, and tear down the newly opened transport if shutdown won the race.

Useful? React with 👍 / 👎.

…shed

The refusal at the entry of `connect` cannot stand for the whole attempt: a
version fetch, a transport and a handshake are awaited after it, and a
shutdown landing in that window was published over, handing back a live
connection on a client the application had been told was finished. Read it
again before the handshake and before publishing, closing the socket opened
for a client that is no longer there.

The run loop reports that abandonment at debug rather than error: it is about
to exit on the same shutdown, so the attempt did not fail, it was called off.
@greptile-apps
greptile-apps Bot dismissed their stale review August 8, 2026 05:22

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

greptile-apps[bot]
greptile-apps Bot previously approved these changes Aug 8, 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 905-913: Synchronize the final shutdown check with transport
publication in connect_graph, covering the awaits and slot-locking path around
the existing transport installation. Ensure signal_shutdown_sync() cannot race
past the check: if shutdown wins, disconnect the local transport and return
ConnectError::Shutdown without publishing it; otherwise publish and return the
connection. Add a deterministic test that fires shutdown after handshake
completes but before publication.
🪄 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: 919e0f2f-6ccc-4870-8f1a-59d8c210debe

📥 Commits

Reviewing files that changed from the base of the PR and between 5494a0c and 92f6daf.

📒 Files selected for processing (2)
  • src/client.rs
  • src/client/lifecycle.rs

Comment thread src/client/lifecycle.rs

@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 1 file (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/client/lifecycle.rs
The check before the handshake still left the slot locks between it and a
reachable connection, and `signal_shutdown_sync` runs no cleanup of its own,
so a shutdown landing across those awaits left a socket installed on a client
that had announced it was gone. Read the signal once more after the slots are
filled and undo the publish if it fired; nothing has been announced at that
point, so clearing the slots and closing the socket is the whole retraction.

From there the connection is reachable with no await in between, so a later
shutdown is an ordinary one on a live connection.
@greptile-apps
greptile-apps Bot dismissed their stale review August 8, 2026 05:27

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

@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 956-967: The final shutdown check and connection publication in
the connection lifecycle method must be serialized through one shared
synchronous transition gate, preventing shutdown from interleaving between
shutdown_signal().is_fired() and is_connected.store(true). Reuse this gate in
terminal shutdown paths; when shutdown acquires it first, retract publication,
clear transport state, disconnect the local transport, and return
ConnectError::Shutdown. Add a deterministic test covering shutdown winning
between the final check and connection publication.
🪄 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: 2dd43b8e-d0ce-4300-809d-4ac393a67f37

📥 Commits

Reviewing files that changed from the base of the PR and between 92f6daf and 726b11c.

📒 Files selected for processing (1)
  • src/client/lifecycle.rs

Comment thread src/client/lifecycle.rs
Comment on lines +956 to +967
// The last word, because the slot locks above are the final awaits a
// shutdown could land across, and `signal_shutdown_sync` runs no
// cleanup of its own. Nothing has been announced yet, so undoing the
// publish here is the whole retraction.
if self.shutdown_signal().is_fired() {
let orphan = self.transport.lock().await.take();
*self.transport_events.lock().await = None;
*self.noise_socket.lock().unwrap_or_else(|p| p.into_inner()) = None;
if let Some(orphan) = orphan {
orphan.disconnect().await;
}
return Err(ConnectError::Shutdown);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Serialize final connection publication with shutdown.

shutdown_signal().is_fired() is only a point-in-time check. If signal_shutdown_sync() runs after Line 960 returns false and before is_connected.store(true) at Line 970, this method returns Ok(Connection) after terminal shutdown.

Use one shared synchronous transition gate for the final shutdown check and connection publication. Use the same gate in terminal shutdown paths. If shutdown wins, retract and close the local transport. Add a deterministic interleaving test.

🤖 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/lifecycle.rs` around lines 956 - 967, The final shutdown check and
connection publication in the connection lifecycle method must be serialized
through one shared synchronous transition gate, preventing shutdown from
interleaving between shutdown_signal().is_fired() and is_connected.store(true).
Reuse this gate in terminal shutdown paths; when shutdown acquires it first,
retract publication, clear transport state, disconnect the local transport, and
return ConnectError::Shutdown. Add a deterministic test covering shutdown
winning between the final check and connection publication.

@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: 3/5

  • In src/client/lifecycle.rs, there is a shutdown/connect race where shutdown can occur after the check but before is_connected.store(true), allowing a “live” connection to be published without a reader and potentially leaving it open; this risks leaked resources and inconsistent lifecycle state — serialize shutdown and connection publication (or make the state transition atomic) so signal_shutdown_sync cannot be bypassed by timing.
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:960">
P1: A shutdown can still publish a live connection by landing after this check but before `is_connected.store(true)`, leaving the readerless connection open because `signal_shutdown_sync` only signals. Serialize shutdown with final publication (or make shutdown retract a concurrently published connection) so this check is a real publication boundary.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/client/lifecycle.rs
// shutdown could land across, and `signal_shutdown_sync` runs no
// cleanup of its own. Nothing has been announced yet, so undoing the
// publish here is the whole retraction.
if self.shutdown_signal().is_fired() {

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: A shutdown can still publish a live connection by landing after this check but before is_connected.store(true), leaving the readerless connection open because signal_shutdown_sync only signals. Serialize shutdown with final publication (or make shutdown retract a concurrently published connection) so this check is a real publication boundary.

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 960:

<comment>A shutdown can still publish a live connection by landing after this check but before `is_connected.store(true)`, leaving the readerless connection open because `signal_shutdown_sync` only signals. Serialize shutdown with final publication (or make shutdown retract a concurrently published connection) so this check is a real publication boundary.</comment>

<file context>
@@ -952,6 +952,21 @@ impl Client {
+        // shutdown could land across, and `signal_shutdown_sync` runs no
+        // cleanup of its own. Nothing has been announced yet, so undoing the
+        // publish here is the whole retraction.
+        if self.shutdown_signal().is_fired() {
+            let orphan = self.transport.lock().await.take();
+            *self.transport_events.lock().await = None;
</file context>

@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.02 MiB 10.03 MiB +4.47 KiB (+0.04%) 🔺
bin .text 8.03 MiB 8.04 MiB +4.31 KiB (+0.05%) 🔺
bin allocated (text+data+bss) 10.02 MiB 10.03 MiB +4.06 KiB (+0.04%) 🔺
llvm-lines wacore 520,546 520,546 0
llvm-lines wacore copies 17,010 17,010 0
llvm-lines whatsapp-rust lib 742,919 743,785 +866 (+0.12%) 🔺
llvm-lines whatsapp-rust lib copies 23,309 23,334 +25 (+0.11%) 🔺
deps crates (Cargo.lock) 462 462 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.82 MiB 1.82 MiB -1.42 KiB (-0.08%) 🔽
.text wacore 691.92 KiB 691.92 KiB 0
.text wacore_binary 88.07 KiB 88.07 KiB 0
.text wacore_libsignal 178.88 KiB 178.88 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 540.30 KiB 540.30 KiB 0
.text whatsapp_rust_tokio_transport 40.49 KiB 40.49 KiB 0
.text whatsapp_rust_ureq_http_client 12.68 KiB 12.68 KiB 0
.text std 992.99 KiB 993.69 KiB +717 B (+0.07%) 🔺
.text other deps 1.90 MiB 1.90 MiB +4.96 KiB (+0.26%) 🔺
Top movers (cargo-bloat attribution)
Crate main PR Δ
metrics_exporter_prometheus (absent) 4.96 KiB +4.96 KiB
whatsapp_rust 1.82 MiB 1.82 MiB -1.42 KiB (-0.08%)

Baseline: e58b7f5ca (latest main run) · Head: 9bb85cb79 · Graphs

@jlucaso1
jlucaso1 merged commit 42b4797 into main Aug 8, 2026
29 checks passed
@jlucaso1
jlucaso1 deleted the fix/connect-run-read-loop-contract branch August 8, 2026 12:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants