Skip to content

fix(client): preserve recipient in <ack>, soften unknown stream:error - #633

Merged
jlucaso1 merged 6 commits into
oxidezap:mainfrom
zdanysfa:fix-recipient-ack
May 21, 2026
Merged

fix(client): preserve recipient in <ack>, soften unknown stream:error#633
jlucaso1 merged 6 commits into
oxidezap:mainfrom
zdanysfa:fix-recipient-ack

Conversation

@zdanysfa

Copy link
Copy Markdown
Contributor

Fix: stop spurious stream disconnects on <stream:error><ack/> notifications

Summary

Two independent fixes — one in the ACK encoder, one in the stream-error handler — that together stop the reconnect storm users have been seeing under the JS bridge as DisconnectReason.badSession (500).

  1. Encoder (encode_ack_bytes) now copies the recipient attribute from the incoming stanza onto the outgoing <ack/>, matching whatsmeow. Without it, server cannot route the ACK back to the originating companion/device and replies with <stream:error><ack/>…</stream:error>.
  2. Handler (handle_stream_error catch-all) no longer treats unknown / code-less stream errors as fatal. It logs + dispatches the StreamError event and leaves the connection alone, exactly like whatsmeow's default branch (connectionevents.go:66-69). The socket layer remains responsible for detecting a real teardown.

Either fix in isolation reduces the symptom; together they remove both the trigger and the over-reaction.


Problem

A bot running this stack repeatedly reconnects with no actionable error. The JS bridge surfaces it as badSession (500), but the server never sends <stream:error code="500"> — that "500" is a bridge-side fallback for expected_disconnect without a recognised code.

Across 19 captured Rust debug logs (7 distinct accounts, ~2-hour window), every disconnect matched this exact shape:

DEBUG <stream:error><ack class="message" type="text" id="2A32F960553696093D99"/></stream:error>
ERROR Unknown stream error: <stream:error><ack class="message" type="text" id="2A32F960553696093D99"/></stream:error>
INFO  Notifying connection shutdown from stream error handler
DEBUG Expected disconnect signaled during frame processing. Exiting message loop.
DEBUG Transport::disconnect called

The <ack> inside the <stream:error> is the server's reference to the message stanza whose ACK it rejected. Immediately after reconnect, that same message is redelivered (offline="10", offline="12"), confirming the previous ACK never registered.

Affected stanzas were a mix of:

  • Peer / hosted-companion messages with recipient="…@lid" and peer_recipient_pn="…".
  • LID-mode group messages (addressing_mode="lid") routed through hosted recipients.
  • Retry placeholders (<enc decrypt-fail="hide"> + <meta tag_reason="rereg_recovery_request">).

Common denominator: they are stanzas where WhatsApp Web / whatsmeow propagate recipient into the ACK. Rust drops it.


Fix A — copy recipient in the ACK encoder

Root cause (Fix A)

whatsmeow/receipt.go:143-177 (Client.sendAck):

attrs := waBinary.Attrs{
    "class": node.Tag,
    "id":    node.Attrs["id"],
}
attrs["to"] = node.Attrs["from"]
if participant, ok := node.Attrs["participant"]; ok {
    attrs["participant"] = participant
}
if recipient, ok := node.Attrs["recipient"]; ok {
    attrs["recipient"] = recipient
    // BotJIDMap remap (Meta-bot only) …
}
if receiptType, ok := node.Attrs["type"]; node.Tag != "message" && ok {
    attrs["type"] = receiptType
}

The server uses recipient to dispatch the ACK to the originating device. Without it the ACK is unroutable; the server signals that by closing the stream with <stream:error><ack …/></stream:error>. The BotJIDMap remap is for Meta's BotServer and has no analogue in whatsapp-rust, so we intentionally do not port it.

Before

whatsapp-rust/src/client.rs::encode_ack_bytes (production path):

let Some(id_val)   = node.get_attr("id")   else { return Ok(None); };
let Some(from_val) = node.get_attr("from") else { return Ok(None); };
let participant_val = node.get_attr("participant").filter(|p| {
    p.as_str().as_ref() != from_val.as_str().as_ref()
});
// (no recipient handling)

let typ_val = if tag != "message" && !is_encrypt_identity_notification(node) {
    node.get_attr("type")
} else { None };

let attr_count = 3
    + usize::from(include_from)
    + usize::from(participant_val.is_some())
    + usize::from(typ_val.is_some());

struct AckNode<'a> {
    id: &'a ValueRef<'a>,
    from: &'a ValueRef<'a>,
    participant: Option<&'a ValueRef<'a>>,
    typ: Option<&'a ValueRef<'a>>,
    own_pn: Option<&'a Jid>,
    tag_str: &'a str,
    attr_count: usize,
}
// encode_attrs: class, id, to, [from], [participant], [type]

After

let Some(id_val)   = node.get_attr("id")   else { return Ok(None); };
let Some(from_val) = node.get_attr("from") else { return Ok(None); };
let participant_val = node.get_attr("participant").filter(|p| {
    p.as_str().as_ref() != from_val.as_str().as_ref()
});
// Server expects `recipient` echoed back so it can route the ack to the
// origin companion/device (hosted-companion, peer, LID-routed stanzas).
// Dropping it makes the server close the stream with `<stream:error><ack/>`.
let recipient_val = node.get_attr("recipient");

let typ_val = if tag != "message" && !is_encrypt_identity_notification(node) {
    node.get_attr("type")
} else { None };

let attr_count = 3
    + usize::from(include_from)
    + usize::from(participant_val.is_some())
    + usize::from(recipient_val.is_some())
    + usize::from(typ_val.is_some());

struct AckNode<'a> {
    id: &'a ValueRef<'a>,
    from: &'a ValueRef<'a>,
    participant: Option<&'a ValueRef<'a>>,
    recipient: Option<&'a ValueRef<'a>>,   // new
    typ: Option<&'a ValueRef<'a>>,
    own_pn: Option<&'a Jid>,
    tag_str: &'a str,
    attr_count: usize,
}
// encode_attrs: class, id, to, [from], [participant], [recipient], [type]

The #[cfg(test)] mirror build_ack_node gets the same shape so structural assertions stay possible without touching the wire encoder.


Fix B — don't proactively disconnect on unknown stream errors

Root cause (Fix B)

whatsmeow/connectionevents.go:19-69 deliberately ignores stream errors it doesn't recognise:

default:
    cli.Log.Errorf("Unknown stream error: %s", node.XMLString())
    go cli.dispatchEvent(&events.StreamError{Code: code, Raw: node})
}

There is no expectDisconnect(), no socket close, no reconnect trigger. The design relies on the read loop noticing if the server actually ends the stream (via xmlstreamend or a closed socket).

whatsapp-rust diverges: its catch-all sets expected_disconnect = true and the function unconditionally calls notify_connection_shutdown() for every branch, including 429, 503, and unknown. That converts an informational stanza into a full shutdown + reconnect cycle, which under high traffic snowballs into the loop the logs show.

Before

whatsapp-rust/src/client.rs::handle_stream_error (excerpt):

match code {
    "515" => { /* … */ should_disconnect = true; }
    "516" | "401" | "409" => { /* … */ should_disconnect = true; }
    "429" => {
        warn!("Got 429 stream error (rate limited). …");
        self.auto_reconnect_errors.fetch_add(5, Ordering::Relaxed);
    }
    "503" => {
        info!("Got 503 service unavailable, will auto-reconnect.");
    }
    _ => {
        error!("Unknown stream error: {}", DisplayableNodeRef(node));
        self.expected_disconnect.store(true, Ordering::Relaxed);     // ← bug
        self.core.event_bus.dispatch(Event::StreamError(/* … */));
    }
}

if should_disconnect {
    let transport_opt = self.transport.lock().await.clone();
    if let Some(transport) = transport_opt {
        self.runtime
            .spawn(Box::pin(async move { transport.disconnect().await; }))
            .detach();
    }
}

info!("Notifying connection shutdown from stream error handler");      // ← always runs
self.notify_connection_shutdown();                                     // ← always runs

After

match code {
    "515" => { /* … */ should_disconnect = true; }
    "516" | "401" | "409" => { /* … */ should_disconnect = true; }
    "429" => {
        warn!("Got 429 stream error (rate limited). …");
        self.auto_reconnect_errors.fetch_add(5, Ordering::Relaxed);
    }
    "503" => {
        info!("Got 503 service unavailable, will auto-reconnect.");
    }
    _ => {
        // Mirror whatsmeow's default: log + dispatch event, but keep the
        // connection alive. The server wraps per-stanza notifications in
        // <stream:error> without a code attribute (e.g. <ack/> for malformed
        // routing). Proactively disconnecting on those causes reconnect storms
        // under load — whatsmeow lets the noise socket detect a real teardown.
        error!("Unknown stream error: {}", DisplayableNodeRef(node));
        self.core.event_bus.dispatch(Event::StreamError(/* … */));
    }
}

// Only tear down the connection for branches that explicitly opted in.
// 429/503/unknown match whatsmeow: log and let the socket layer notice
// if the server actually closes the stream.
if should_disconnect {
    let transport_opt = self.transport.lock().await.clone();
    if let Some(transport) = transport_opt {
        self.runtime
            .spawn(Box::pin(async move { transport.disconnect().await; }))
            .detach();
    }
    info!("Notifying connection shutdown from stream error handler");
    self.notify_connection_shutdown();
}

Two concrete behavior changes:

  1. The catch-all no longer flips expected_disconnect. If the server really closes the stream afterwards, the existing socket-level path handles it; if it doesn't, the connection lives.
  2. notify_connection_shutdown() (and the matching info! log) is now inside the should_disconnect block. The implicit side effect — 429 and 503 were also triggering shutdown — is gone.

This brings Rust's behavior matrix in line with whatsmeow:

Stream error whatsmeow Rust before this PR Rust after this PR
code="515" Disconnect + reconnect Disconnect + shutdown Disconnect + shutdown
code="516" / "401" / "409" / conflict Disconnect, disable reconnect Disconnect + shutdown, disable reconnect Disconnect + shutdown, disable reconnect
code="429" Log + dispatch (no shutdown) Shutdown Log + dispatch (no shutdown)
code="503" Log + dispatch (no shutdown) Shutdown Log + dispatch (no shutdown)
Unknown / no code (e.g. <ack/> body) Log + dispatch (no shutdown) Shutdown + expected_disconnect=true Log + dispatch (no shutdown)

Tests

Five new unit tests in src/client.rs (existing test module).

Encoder (Fix A)

  • test_build_ack_node_for_message_with_recipient_preserves_recipient — message stanza with recipient="146991363395800@lid" produces an ACK that echoes the same value.
  • test_build_ack_node_for_receipt_with_recipient_preserves_recipient — same for <receipt>.
  • test_build_ack_node_for_message_without_recipient_omits_recipient — regression guard: never synthesise an empty recipient.

Handler (Fix B)

  • test_stream_error_unknown_keeps_connection_alive — bare <stream:error/> (no code, no body) leaves both expected_disconnect and enable_auto_reconnect untouched.
  • test_stream_error_ack_shaped_does_not_force_shutdown — the exact shape seen in production (<stream:error><ack class="message" type="text" id="…"/></stream:error>) does not flip expected_disconnect.

All fictitious JIDs, per AGENTS.md ("No real PII in tests").

Local results:

$ cargo test --lib client::tests::test_build_ack_node
running 9 tests
test result: ok. 9 passed; 0 failed; 0 ignored

$ cargo test --lib client::tests::test_stream_error
running 6 tests
test result: ok. 6 passed; 0 failed; 0 ignored

Workspace-wide (cargo test --workspace --exclude e2e-tests) passes 511 + sub-crate tests with no regressions. e2e-tests is skipped because it requires the mock server (per AGENTS.md).


Alignment with whatsapp-rust/AGENTS.md

Rule This PR
Cross-reference whatsmeow, Baileys, and captured WhatsApp Web JS to verify implementations. Both fixes are direct ports of whatsmeow (receipt.go:143-177 for A, connectionevents.go:19-69 for B). Each change cites its whatsmeow line range in code comments.
Collapsible if: always use let-chains instead of nested if let. New code uses the same if let Some(_) = … single-bind pattern already present; no nested if let introduced.
No real PII in tests. All test JIDs are fictitious (146991363395800@lid, 155500012345:48@s.whatsapp.net, etc.).
State: never modify Device state directly. Use DeviceCommand + PersistenceManager::process_command(). Neither fix touches Device state.
Errors: thiserror for typed errors, no .unwrap() outside tests. New code returns early via the existing Result paths; no new .unwrap() introduced outside tests.
When adding comments to the code, don't be so verbose, also only explain why, not what. Both new comment blocks describe the WHY (server routing requirement, whatsmeow parity) — not the WHAT.
Rust style — cargo fmt --all, cargo clippy --all --tests, cargo test --all. All three are clean.

For reference: NackInvalidHostedCompanionStanza = 493 already exists in wacore/src/protocol/nack.rs. That nack family is exactly what the server is signalling with the ack-shaped stream error before this fix — surfaced at the stream layer instead of as a nack.


Updated stream error handling to maintain connection during unknown errors and added recipient attribute handling in ACK nodes.
@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

This PR was authored by the user configured for CodeRabbit reviews. CodeRabbit does not review PRs authored by this user. It's recommended to use a dedicated user account to post CodeRabbit review feedback.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d827fd54-ba28-44e4-891a-95c1f4c185de

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Unknown or ack-shaped <stream:error> no longer mark disconnect as expected and leave teardown to the socket layer; ACK construction now preserves an incoming recipient attribute and test helpers/tests updated accordingly.

Changes

Client stream handling and ACK encoding fixes

Layer / File(s) Summary
Stream error handling and reconnection logic
src/client.rs
Unknown and ack-shaped <stream:error> branches now log and dispatch a StreamError event without setting expected_disconnect; is_logged_in is cleared and notify_connection_shutdown() invoked only for explicit proactive disconnect paths. Unit tests added to assert login-state, expected_disconnect, and auto-reconnect behavior.
ACK stanza recipient attribute encoding
src/client.rs
encode_ack_bytes extracts optional incoming recipient, adjusts attribute length accounting, stores it in AckNode, and conditionally writes recipient="..." into outgoing <ack/>. build_ack_node test helper and unit tests updated to preserve or omit recipient on roundtrip.

Sequence Diagram(s)

sequenceDiagram
  participant Socket
  participant Client
  participant StreamHandler
  Socket->>Client: deliver <stream:error> (no code or contains <ack/>)
  Client->>StreamHandler: handle_stream_error
  StreamHandler->>Client: dispatch StreamError event (no expected_disconnect)
  alt should_disconnect true
    StreamHandler->>Client: clear is_logged_in / set expected_disconnect
    Client->>Client: notify_connection_shutdown()
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

breaking-change

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the two main changes: preserving recipient in ACK encoding and softening unknown stream error handling.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, detailing both fixes, root causes, and test coverage.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client.rs (1)

3216-3336: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t clear auth state on the non-fatal stream:error path.

Line 3217 runs before you decide whether this error should actually tear the connection down. After this change, 429, 503, and code-less / ack-shaped stream:error stanzas keep the socket alive, so the client can end up connected with is_logged_in == false. That flips is_fully_ready() false and can break wait_for_connected() and other login-gated flows even though we intentionally stayed online.

Suggested fix
 pub(crate) async fn handle_stream_error(&self, node: &wacore_binary::NodeRef<'_>) {
-    self.is_logged_in.store(false, Ordering::Relaxed);
-
     let mut attrs = node.attrs();
     let code_cow = attrs.optional_string("code");
     let code = code_cow.as_deref().unwrap_or("");
@@
         if should_disconnect {
+            self.is_logged_in.store(false, Ordering::Relaxed);
             let transport_opt = self.transport.lock().await.clone();
             if let Some(transport) = transport_opt {
                 self.runtime

If any event handlers need to observe the logged-out state during dispatch, set the flag inside each fatal branch before dispatch(...) instead of once up front. Also pin this with a regression assert in the new unknown-stream-error tests.

🤖 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 3216 - 3336, In handle_stream_error, don't
unconditionally clear self.is_logged_in at the top; instead set
self.is_logged_in.store(false, Ordering::Relaxed) only inside the fatal branches
that actually log out / disconnect (the conflict branch where conflict_type !=
"", and the explicit fatal code branches: "515", "516", "401", "409" — and any
other branches you consider fatal) immediately before setting
expected_disconnect/enable_auto_reconnect and dispatching the
LoggedOut/StreamReplaced events; leave 429, "503", and the default/code-less
branch untouched so the client remains logged in. Also add a regression
assertion in the new unknown-stream-error test to ensure is_logged_in remains
true for non-fatal errors.
🤖 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.

Outside diff comments:
In `@src/client.rs`:
- Around line 3216-3336: In handle_stream_error, don't unconditionally clear
self.is_logged_in at the top; instead set self.is_logged_in.store(false,
Ordering::Relaxed) only inside the fatal branches that actually log out /
disconnect (the conflict branch where conflict_type != "", and the explicit
fatal code branches: "515", "516", "401", "409" — and any other branches you
consider fatal) immediately before setting
expected_disconnect/enable_auto_reconnect and dispatching the
LoggedOut/StreamReplaced events; leave 429, "503", and the default/code-less
branch untouched so the client remains logged in. Also add a regression
assertion in the new unknown-stream-error test to ensure is_logged_in remains
true for non-fatal errors.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d2a1cbeb-9930-4c9f-b640-b0ee24796e12

📥 Commits

Reviewing files that changed from the base of the PR and between 20f1ce8 and e8c8ed7.

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

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client.rs (1)

3314-3343: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't clear login state for non-fatal stream:error.

This change intentionally keeps unknown / ack-shaped stream:error stanzas on the current socket, but Line 3225 still sets is_logged_in to false before we know whether the branch will disconnect. That leaves the client in a live-but-logged-out state after informational errors, which can break wait_for_connected() and any callers checking is_logged_in().

Suggested fix
 pub(crate) async fn handle_stream_error(&self, node: &wacore_binary::NodeRef<'_>) {
-        self.is_logged_in.store(false, Ordering::Relaxed);
-
         let mut attrs = node.attrs();
         let code_cow = attrs.optional_string("code");
         let code = code_cow.as_deref().unwrap_or("");
         let conflict_type = node
             .get_optional_child("conflict")
@@
         if should_disconnect {
+            self.is_logged_in.store(false, Ordering::Relaxed);
             let transport_opt = self.transport.lock().await.clone();
             if let Some(transport) = transport_opt {
                 self.runtime
                     .spawn(Box::pin(async move {
                         transport.disconnect().await;
🤖 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 3314 - 3343, The handler currently clears
self.is_logged_in regardless of whether the stream error is fatal; update the
logic so is_logged_in is only set to false when should_disconnect is true (i.e.,
when you actually tear down the connection). Concretely, remove or defer the
code that sets self.is_logged_in = false from the early/unknown-error path and
move it into the branch where you perform the disconnect (the block that checks
should_disconnect, acquires transport via self.transport.lock().await, spawns
transport.disconnect(), calls self.notify_connection_shutdown(), and logs the
shutdown). This preserves login state for non-fatal/ack-shaped stream errors
while ensuring it is cleared when you intentionally disconnect.
🤖 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.rs`:
- Around line 5896-5969: Add a test that exercises the real encoder: instead of
only calling build_ack_node(), call encode_ack_bytes(&incoming.as_node_ref(),
Some(&own_device_pn)) to get the serialized ACK, then parse the serialized bytes
back into a Node (e.g. via the repository's node-decoding helper such as
decode_node_from_bytes or equivalent) and assert the parsed ACK node has the
correct "class" and that "recipient" is present/echoed when the incoming had one
and absent when it did not; this ensures encode_ack_bytes (not just
build_ack_node) preserves/omits the recipient as expected.

---

Outside diff comments:
In `@src/client.rs`:
- Around line 3314-3343: The handler currently clears self.is_logged_in
regardless of whether the stream error is fatal; update the logic so
is_logged_in is only set to false when should_disconnect is true (i.e., when you
actually tear down the connection). Concretely, remove or defer the code that
sets self.is_logged_in = false from the early/unknown-error path and move it
into the branch where you perform the disconnect (the block that checks
should_disconnect, acquires transport via self.transport.lock().await, spawns
transport.disconnect(), calls self.notify_connection_shutdown(), and logs the
shutdown). This preserves login state for non-fatal/ack-shaped stream errors
while ensuring it is cleared when you intentionally disconnect.
🪄 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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b9c33023-50bc-4098-a4a8-751963a3d1fb

📥 Commits

Reviewing files that changed from the base of the PR and between e8c8ed7 and 2db09cf.

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

Comment thread src/client.rs
Refactor stream error handling to only clear is_logged_in in fatal cases, ensuring session remains alive for non-fatal errors.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client.rs (1)

6114-6129: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Assert the per-connection shutdown signal stays quiet here.

This is the core contract. Right now these tests only prove the flags stayed stable. If notify_connection_shutdown() slips back into the non-fatal path, both tests still pass and we get the reconnect storm again. Add an explicit !client.connection_shutdown_signal().is_fired() assertion in both cases.

Proposed test tightening
         client.handle_stream_error(&node.as_node_ref()).await;
         assert!(
             client.is_logged_in.load(Ordering::Relaxed),
             "unknown stream:error must NOT log the client out"
         );
+        assert!(
+            !client.connection_shutdown_signal().is_fired(),
+            "unknown stream:error must NOT fire per-connection shutdown"
+        );
         assert!(
             !client.expected_disconnect.load(Ordering::Relaxed),
             "unknown stream:error must not mark the disconnect as expected"
         );
         client.handle_stream_error(&node.as_node_ref()).await;
         assert!(
             client.is_logged_in.load(Ordering::Relaxed),
             "ack-shaped stream:error must NOT log the client out"
         );
+        assert!(
+            !client.connection_shutdown_signal().is_fired(),
+            "ack-shaped stream:error must NOT fire per-connection shutdown"
+        );
         assert!(
             !client.expected_disconnect.load(Ordering::Relaxed),
             "ack-shaped stream:error must not mark the disconnect as expected"
         );

Also applies to: 6138-6155

🤖 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 6114 - 6129, Add assertions to ensure the
per-connection shutdown signal remains unfired after handling unknown stream
errors: after calling client.handle_stream_error(&node.as_node_ref()) in the
test block that sets client.is_logged_in and checks flags, also assert
!client.connection_shutdown_signal().is_fired() so notify_connection_shutdown()
hasn't been invoked; do the same in the other test case around lines 6138-6155.
This uses the existing NodeBuilder and connection_shutdown_signal() API and
prevents slipping notify_connection_shutdown() into the non-fatal path.
🤖 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.

Outside diff comments:
In `@src/client.rs`:
- Around line 6114-6129: Add assertions to ensure the per-connection shutdown
signal remains unfired after handling unknown stream errors: after calling
client.handle_stream_error(&node.as_node_ref()) in the test block that sets
client.is_logged_in and checks flags, also assert
!client.connection_shutdown_signal().is_fired() so notify_connection_shutdown()
hasn't been invoked; do the same in the other test case around lines 6138-6155.
This uses the existing NodeBuilder and connection_shutdown_signal() API and
prevents slipping notify_connection_shutdown() into the non-fatal path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 69b6441a-31bd-444f-91c8-1d1fb9b4f5fb

📥 Commits

Reviewing files that changed from the base of the PR and between 2db09cf and d35c51a.

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

Refactor comments for clarity and add unit test for ACK encoding.
…gence

Drop the four per-branch is_logged_in.store(false) calls in 515/516/401/409
now that the if-should_disconnect block clears it once for every opt-in
path (including conflict). Rewrite the catch-all comment to make the
deliberate divergence from whatsmeow explicit: whatsmeow eagerly clears
isLoggedIn, but here is_fully_ready() gates prekey uploads, so we keep
it true while the socket is still alive.

@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.rs`:
- Around line 3316-3323: The log here is intentionally non-fatal but still uses
error! which contradicts the “soften unknown stream:error” intent; change the
severity from error! to warn! for the Unknown stream error logging (the call
that currently does error!("Unknown stream error: {}",
DisplayableNodeRef(node))) so it doesn't trigger pager/alerting while still
recording the event before calling
self.core.event_bus.dispatch(Event::StreamError(...)).
🪄 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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 94fc71fd-1670-4c6b-88ff-9b3f08fbe765

📥 Commits

Reviewing files that changed from the base of the PR and between 1ab104b and 895dc72.

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

Comment thread src/client.rs
…o warn

429 and 503 are explicit server-side rejections of the current session.
Pre-PR, the catch-all cleared is_logged_in for every code (including
429/503) — too aggressive for unknown/<ack/>-shaped routing errors, but
correct for these two: while the socket is still up and reconnect hasn't
fired yet, outgoing sends would otherwise either retry against a dying
socket (503) or fuel the rate-limit the server just flagged (429).
Whatsmeow clears isLoggedIn at the very top for all branches; this lines
up Rust with that intent for 429/503 specifically, leaving the unknown
catch-all alone so prekey uploads keep working through routing-error
storms.

Downgrade the unknown-stream-error log from error! to warn!: the
connection is intentionally preserved, so this is not a fatal event and
shouldn't page on logs that match error severity.

Tests updated: 429/503 now also assert is_logged_in flips to false and
expected_disconnect stays false.

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/client.rs (1)

3247-3305: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear is_logged_in before firing logout/replaced events.

This path now emits LoggedOut / StreamReplaced while is_logged_in is still true, because the shared clear only happens later in the disconnect block. That means handlers can observe a stale session state on the fatal path, which is inconsistent with the “dispatch after cleanup so handlers see cleared state” pattern elsewhere in this file.

Suggested fix
+        let mut disconnect_event = None;
+
         if !conflict_type.is_empty() {
             info!(
                 "Got stream error indicating client was removed or replaced (conflict={}). Logging out.",
                 conflict_type
             );
             self.expected_disconnect.store(true, Ordering::Relaxed);
             self.enable_auto_reconnect.store(false, Ordering::Relaxed);
 
-            let event = if conflict_type == "replaced" {
+            disconnect_event = Some(if conflict_type == "replaced" {
                 Event::StreamReplaced(crate::types::events::StreamReplaced)
             } else {
                 Event::LoggedOut(crate::types::events::LoggedOut {
                     on_connect: false,
                     reason: ConnectFailureReason::LoggedOut,
                 })
-            };
-            self.core.event_bus.dispatch(event);
+            });
             should_disconnect = true;
         } else {
             match code {
                 "515" => {
@@
                 "516" => {
                     info!("Got 516 stream error (device removed). Logging out.");
                     self.expected_disconnect.store(true, Ordering::Relaxed);
                     self.enable_auto_reconnect.store(false, Ordering::Relaxed);
-                    self.core.event_bus.dispatch(Event::LoggedOut(
+                    disconnect_event = Some(Event::LoggedOut(
                         crate::types::events::LoggedOut {
                             on_connect: false,
                             reason: ConnectFailureReason::LoggedOut,
                         },
-                    ));
+                    ));
                     should_disconnect = true;
                 }
                 "401" => {
                     info!("Got 401 stream error (unauthorized). Logging out.");
                     self.expected_disconnect.store(true, Ordering::Relaxed);
                     self.enable_auto_reconnect.store(false, Ordering::Relaxed);
-                    self.core.event_bus.dispatch(Event::LoggedOut(
+                    disconnect_event = Some(Event::LoggedOut(
                         crate::types::events::LoggedOut {
                             on_connect: false,
                             reason: ConnectFailureReason::LoggedOut,
                         },
-                    ));
+                    ));
                     should_disconnect = true;
                 }
                 "409" => {
                     info!("Got 409 stream error (conflict). Another session replaced this one.");
                     self.expected_disconnect.store(true, Ordering::Relaxed);
                     self.enable_auto_reconnect.store(false, Ordering::Relaxed);
-                    self.core
-                        .event_bus
-                        .dispatch(Event::StreamReplaced(crate::types::events::StreamReplaced));
+                    disconnect_event =
+                        Some(Event::StreamReplaced(crate::types::events::StreamReplaced));
                     should_disconnect = true;
                 }
@@
         if should_disconnect {
             self.is_logged_in.store(false, Ordering::Relaxed);
+            if let Some(event) = disconnect_event {
+                self.core.event_bus.dispatch(event);
+            }
             let transport_opt = self.transport.lock().await.clone();
             if let Some(transport) = transport_opt {
                 self.runtime

Also applies to: 3344-3346

🤖 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 3247 - 3305, Before dispatching LoggedOut or
StreamReplaced events, clear the client's is_logged_in flag so handlers don't
observe a stale logged-in state; specifically, set
self.is_logged_in.store(false, Ordering::Relaxed) (or call the shared
clear/login-reset helper if one exists) immediately before any
self.core.event_bus.dispatch(...) in the conflict_type branch (where
Event::StreamReplaced / Event::LoggedOut are created) and in the individual
error-code branches ("516", "401", "409" and the other conflict path),
preserving the existing expected_disconnect and enable_auto_reconnect changes.
🤖 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.rs`:
- Around line 3307-3321: The "429" and "503" match arms currently change
teardown flags (self.is_logged_in, self.auto_reconnect_errors) but no longer
emit the structured Event::StreamError, making these conditions invisible to
consumers; update the "429" and "503" branches inside the stream error match to
construct and send/publish an Event::StreamError (including the status code
string and a brief message) to the same event sink/dispatcher used elsewhere
before or immediately after changing self.is_logged_in and auto_reconnect_errors
so callers receive the stream error while the socket teardown behavior remains
unchanged (refer to Event::StreamError, the "429"/"503" match arms,
self.is_logged_in.store, and self.auto_reconnect_errors.fetch_add).

---

Outside diff comments:
In `@src/client.rs`:
- Around line 3247-3305: Before dispatching LoggedOut or StreamReplaced events,
clear the client's is_logged_in flag so handlers don't observe a stale logged-in
state; specifically, set self.is_logged_in.store(false, Ordering::Relaxed) (or
call the shared clear/login-reset helper if one exists) immediately before any
self.core.event_bus.dispatch(...) in the conflict_type branch (where
Event::StreamReplaced / Event::LoggedOut are created) and in the individual
error-code branches ("516", "401", "409" and the other conflict path),
preserving the existing expected_disconnect and enable_auto_reconnect changes.
🪄 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

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e6aee783-390f-450c-8ad0-930eb22e2855

📥 Commits

Reviewing files that changed from the base of the PR and between 1ab104b and 4c7acf5.

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

Comment thread src/client.rs
Comment on lines 3307 to +3321
"429" => {
// Server signalled rate-limit on this session: mark logged-out so
// outgoing sends bail fast instead of being interpreted as abuse
// while we wait for the (likely-imminent) reconnect.
warn!(
"Got 429 stream error (rate limited). Will auto-reconnect with extended backoff."
);
self.is_logged_in.store(false, Ordering::Relaxed);
self.auto_reconnect_errors.fetch_add(5, Ordering::Relaxed);
}
"503" => {
// Server is going down/restarting: mark logged-out so sends fail
// fast against the soon-to-die socket. Auto-reconnect handles recovery.
info!("Got 503 service unavailable, will auto-reconnect.");
self.is_logged_in.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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep 429 and 503 visible via Event::StreamError.

These branches changed the teardown behavior, but they also stopped surfacing a structured stream-error event. That makes rate-limit and service-unavailable rejections invisible to consumers exactly in the cases where the transport is intentionally left alive for the socket layer to finish the shutdown.

Suggested fix
                 "429" => {
                     // Server signalled rate-limit on this session: mark logged-out so
                     // outgoing sends bail fast instead of being interpreted as abuse
                     // while we wait for the (likely-imminent) reconnect.
                     warn!(
                         "Got 429 stream error (rate limited). Will auto-reconnect with extended backoff."
                     );
                     self.is_logged_in.store(false, Ordering::Relaxed);
+                    self.core.event_bus.dispatch(Event::StreamError(
+                        crate::types::events::StreamError {
+                            code: code.to_string(),
+                            raw: Some(node.to_owned()),
+                        },
+                    ));
                     self.auto_reconnect_errors.fetch_add(5, Ordering::Relaxed);
                 }
                 "503" => {
                     // Server is going down/restarting: mark logged-out so sends fail
                     // fast against the soon-to-die socket. Auto-reconnect handles recovery.
                     info!("Got 503 service unavailable, will auto-reconnect.");
                     self.is_logged_in.store(false, Ordering::Relaxed);
+                    self.core.event_bus.dispatch(Event::StreamError(
+                        crate::types::events::StreamError {
+                            code: code.to_string(),
+                            raw: Some(node.to_owned()),
+                        },
+                    ));
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"429" => {
// Server signalled rate-limit on this session: mark logged-out so
// outgoing sends bail fast instead of being interpreted as abuse
// while we wait for the (likely-imminent) reconnect.
warn!(
"Got 429 stream error (rate limited). Will auto-reconnect with extended backoff."
);
self.is_logged_in.store(false, Ordering::Relaxed);
self.auto_reconnect_errors.fetch_add(5, Ordering::Relaxed);
}
"503" => {
// Server is going down/restarting: mark logged-out so sends fail
// fast against the soon-to-die socket. Auto-reconnect handles recovery.
info!("Got 503 service unavailable, will auto-reconnect.");
self.is_logged_in.store(false, Ordering::Relaxed);
"429" => {
// Server signalled rate-limit on this session: mark logged-out so
// outgoing sends bail fast instead of being interpreted as abuse
// while we wait for the (likely-imminent) reconnect.
warn!(
"Got 429 stream error (rate limited). Will auto-reconnect with extended backoff."
);
self.is_logged_in.store(false, Ordering::Relaxed);
self.core.event_bus.dispatch(Event::StreamError(
crate::types::events::StreamError {
code: code.to_string(),
raw: Some(node.to_owned()),
},
));
self.auto_reconnect_errors.fetch_add(5, Ordering::Relaxed);
}
"503" => {
// Server is going down/restarting: mark logged-out so sends fail
// fast against the soon-to-die socket. Auto-reconnect handles recovery.
info!("Got 503 service unavailable, will auto-reconnect.");
self.is_logged_in.store(false, Ordering::Relaxed);
self.core.event_bus.dispatch(Event::StreamError(
crate::types::events::StreamError {
code: code.to_string(),
raw: Some(node.to_owned()),
},
));
}
🤖 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 3307 - 3321, The "429" and "503" match arms
currently change teardown flags (self.is_logged_in, self.auto_reconnect_errors)
but no longer emit the structured Event::StreamError, making these conditions
invisible to consumers; update the "429" and "503" branches inside the stream
error match to construct and send/publish an Event::StreamError (including the
status code string and a brief message) to the same event sink/dispatcher used
elsewhere before or immediately after changing self.is_logged_in and
auto_reconnect_errors so callers receive the stream error while the socket
teardown behavior remains unchanged (refer to Event::StreamError, the
"429"/"503" match arms, self.is_logged_in.store, and
self.auto_reconnect_errors.fetch_add).

@jlucaso1
jlucaso1 merged commit 0259de8 into oxidezap:main May 21, 2026
8 of 10 checks passed
@jlucaso1

Copy link
Copy Markdown
Collaborator

Thanks

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