Skip to content

fix(conn): log benign server recycles quietly without hiding real errors - #785

Merged
jlucaso1 merged 3 commits into
mainfrom
fix/classify-benign-disconnect-log-level
Jun 8, 2026
Merged

fix(conn): log benign server recycles quietly without hiding real errors#785
jlucaso1 merged 3 commits into
mainfrom
fix/classify-benign-disconnect-log-level

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

The prod logs (last 3 days) show the only recurring WARN/ERROR are benign connection recycles: the WhatsApp server periodically drops the stream (a clean EOF, or a <xmlstreamend/>) and we reconnect within ~1s and re-auth fine — but each one was logged as WARN "Message loop exited with an error" + WARN "Received <xmlstreamend/>, treating as disconnect", and a keepalive ping firing during teardown logged WARN "Keepalive ping failed: NotConnected". ~4-5 of these a day, 0 real errors. The noise would bury a genuine failure.

This changes only the log level, never the behavior — we still reconnect and still dispatch Event::Disconnected. The guiding rule is to never hide a real error:

  • New DisconnectReason::is_clean_shutdown(): a clean EOF (StreamEnded) or a Close frame with a normal/going-away/absent code is quiet; a ReadError, an abnormal close code, or an unreported (Unknown) reason stay loud. Deliberately conservative — anything ambiguous returns false (loud).
  • The read loop logs a clean disconnect at info and a real one at warn (previously a generic debug here plus a generic warn in the lifecycle). The lifecycle's generic "Message loop exited" warn drops to debug, since the read loop now owns the classified, reason-bearing message.
  • <xmlstreamend/> (a clean server stream end) logs at info.
  • Keepalive: a fatal-classified failure (connection already gone, e.g. NotConnected) logs at debug (it's teardown collateral; the disconnect itself is logged by the read loop); a transient failure (timeout / unexpected response) stays at warn.

Not hiding real errors — tested both ways

  • net.rs: clean_shutdowns_are_classified_clean (StreamEnded, ServerClose{1000,1001,None} → clean/quiet) and real_errors_are_never_classified_clean (ReadError, Unknown, and codes 1002/1006/1011/1012/1013/3000/4000 → not clean/loud).
  • keepalive: existing test_classify_timeout_is_transient (real keepalive failure → Transient → stays warn) and test_classify_not_connected_is_fatal (benign → Fatal → debug).

Internal logging change only; DisconnectReason gains a method (no field/variant change), e2e compiles unchanged.

jlucaso1 added 2 commits June 8, 2026 15:42
Prod logs show the only recurring WARNs are benign connection recycles: the
WhatsApp server periodically drops the stream (clean EOF or <xmlstreamend/>)
and we reconnect, but each one logged WARN 'Message loop exited with an error'
+ 'treating as disconnect', and a keepalive firing mid-teardown logged WARN
'Keepalive ping failed: NotConnected'. These are routine, not errors, and the
noise buries genuine failures.

Classify the log level by cause, never the behavior (we still reconnect and
dispatch Event::Disconnected):
- Add DisconnectReason::is_clean_shutdown() — clean EOF / normal-or-absent close
  code are quiet; a ReadError, an abnormal close code, or an unreported reason
  stay loud. Deliberately conservative: anything ambiguous stays loud.
- Read loop logs a clean disconnect at info, a real one at warn (was a generic
  debug + a lifecycle WARN); lifecycle's generic WARN drops to debug since the
  read loop now owns the classified, reason-bearing message.
- <xmlstreamend/> (a clean server stream end) logs at info, not warn.
- Keepalive: a fatal-classified failure (connection already gone, e.g.
  NotConnected) logs at debug; a transient one (timeout / bad response) stays warn.

Tests cover both paths so real errors are never hidden: is_clean_shutdown
classifies StreamEnded / ServerClose{1000,1001,None} clean and ReadError /
abnormal codes / Unknown not-clean; the existing keepalive classify tests pin
Timeout->Transient (loud) and NotConnected->Fatal (quiet).
Keep the benign transport-channel-close visible (info) rather than burying it
at debug after the lifecycle WARN was dropped — it has no DisconnectReason so
we can't prove it clean, but it isn't a read error either.
@coderabbitai

coderabbitai Bot commented Jun 8, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 93656122-db7f-44d3-90a6-4a02b77da4ee

📥 Commits

Reviewing files that changed from the base of the PR and between 0b3046d and 46bf19a.

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

📝 Walkthrough

Summary by CodeRabbit

  • Refactor
    • Refined connection and keepalive handling: richer classification of clean vs. error shutdowns, quieter logs for benign teardowns, clearer info/warn/debug distinctions, and preserved reconnect behavior.
  • Tests
    • Added unit tests to validate shutdown classification and keepalive error treatment.

Walkthrough

This PR adds DisconnectReason::is_clean_shutdown() to identify benign server stream recycling and updates read loop, Client::run, and send_keepalive to lower log verbosity for clean teardowns while preserving existing reconnect/error-return behavior.

Changes

Clean shutdown classification and reconnect logging

Layer / File(s) Summary
Clean shutdown classification contract and tests
wacore/src/net.rs
DisconnectReason::is_clean_shutdown() classifies EOF and server Close codes 1000, 1001, or no close code as clean; read errors, unknown reasons, and other Close codes are non-clean. Unit tests validate clean and abnormal cases across a set of Close codes.
Disconnect logging integration
src/client/node_io.rs, src/client/lifecycle.rs, src/keepalive.rs
read_messages_loop logs clean stream ends at info and abnormal disconnects at warn; Client::run captures the message loop result and logs unexpected-disconnect details at debug; send_keepalive uses a new is_benign_teardown helper to log benign teardown errors at debug and other failures at warn. Unit tests for the benign-teardown helper are included.

Sequence Diagram

sequenceDiagram
  participant node_io as read_messages_loop
  participant lifecycle as Client::run
  participant keepalive as send_keepalive
  participant net as DisconnectReason
  
  node_io->>net: is_clean_shutdown() on TransportEvent::Disconnected
  net-->>node_io: true/false classification
  node_io->>node_io: Log at info (clean) or warn (abnormal)
  
  lifecycle->>node_io: await read_messages_loop()
  node_io-->>lifecycle: Err(e) result
  lifecycle->>lifecycle: Log at debug with error details
  
  keepalive->>keepalive: Ping fails with error
  keepalive->>keepalive: classify_keepalive_error()
  keepalive->>keepalive: Log at debug (benign teardown) or warn (real failure)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

api-design

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main change: reducing log noise from benign server reconnections while preserving error visibility.
Description check ✅ Passed The description comprehensively explains the logging changes, the rationale (reducing production log noise), and explicitly confirms behavioral semantics are preserved.
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.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/classify-benign-disconnect-log-level

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 and usage tips.

@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: 1832f6e357

ℹ️ 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
// error), so keep this at debug to avoid re-flagging a benign
// reconnect as an error. Still treated as an unexpected
// disconnect for the event dispatch + reconnect below.
debug!("Message loop exited, will reconnect if enabled: {e:#}");

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 unclassified read-loop exits visible

When the transport event channel closes without delivering a Disconnected event, read_messages_loop takes the Err(_) branch in src/client/node_io.rs and only emits a debug message before returning Err("Transport event channel closed"). This new generic debug log is therefore the only record of that unexpected disconnect, so custom/buggy transports that drop the sender without a reason will reconnect and dispatch Disconnected without any warn/error even though the cause was not classified as clean.

Useful? React with 👍 / 👎.

Comment thread src/keepalive.rs Outdated
Comment on lines +99 to +100
KeepaliveResult::FatalFailure => {
debug!(target: "Client/Keepalive", "Keepalive skipped, connection already closing: {e:?}");

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 real keepalive send failures at warn

This debug branch now covers every FatalFailure, but classify_keepalive_error maps more than teardown cases to fatal, including IqError::Socket(_), EncryptSend(_), ClientState(_), InternalChannelClosed, and EncodeError(_). If a keepalive ping fails because the socket/send pipeline breaks while the client still thinks it is connected, this becomes the only keepalive log before the loop exits, so a real connection/send failure is hidden at debug instead of staying loud.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

Benchmark Results

67 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 2,925 2,925 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 8,446 8,446 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 31,317 31,317 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 13,827 13,827 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 49,485 49,485 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 55,001 55,001 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 1,679 1,679 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 4,393 4,393 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 112,965 113,074 -0.1%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,731 1,656,622 +0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 651,745 651,741 +0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 875,621 875,832 -0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 2,083,512 2,083,639 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 749,076 748,275 +0.1%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,325,964 1,329,834 -0.3%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,379,101 4,376,491 +0.1%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 518,416 515,605 +0.5%
binary_benchmark::marshal_group::bench_marshal_allocating 45,381 45,381 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 45,431 45,431 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 66,334 66,334 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 43,492 43,492 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 45,487 45,487 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 4,945 4,945 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 4,976 4,976 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,747 6,747 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,544 528,544 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 528,165 528,165 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,411 529,411 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 5,417,732 5,417,732 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 5,362,047 5,362,047 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 13,276,365 13,276,365 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 1,850 1,850 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 29,217 29,217 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 618 618 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 672,890 672,890 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 3,736 3,736 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 3,840 3,840 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 48,274 48,274 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 3,866 3,866 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 48,335 48,335 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 5,206 5,206 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 66,659 66,659 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 310,312 310,312 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 8,291 8,291 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u32 254 254 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u32 91 91 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u64 292 292 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u64 137 137 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_i64 317 317 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_i64 145 145 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_loop_100_u64 27,425 27,425 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_loop_100_u64 10,725 10,725 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 4,140,996 4,138,263 +0.1%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,133 100,133 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 4,264,189 4,264,189 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 100,399 100,399 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 210,262 210,262 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 496,921 496,921 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 509,077 512,029 -0.6%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,978,273 11,977,196 +0.0%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 2,466,138 2,466,138 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 4,932,422 4,913,252 +0.4%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,397 2,043,397 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,404 37,404 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 3,617,967 3,617,967 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 230,658 230,648 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 9,980,959 9,980,959 +0.0%
No significant changes detected.

@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/keepalive.rs (1)

91-109: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove unreachable pattern arm.

Look, at line 102, you're matching KeepaliveResult::Ok in the error handling branch. We're inside Err(e) from line 91, and classify_keepalive_error can only return FatalFailure or TransientFailure based on lines 31-46. The Ok variant is impossible here—it only appears in the success path at line 89. This is dead code that could confuse engineers debugging keepalive failures.

🔧 Proposed fix
-                    KeepaliveResult::TransientFailure | KeepaliveResult::Ok => {
+                    KeepaliveResult::TransientFailure => {
                         warn!(target: "Client/Keepalive", "Keepalive ping failed: {e:?}");
                     }
🤖 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/keepalive.rs` around lines 91 - 109, The match in the Err(e) branch calls
classify_keepalive_error(&e) but includes a KeepaliveResult::Ok arm which is
impossible there; update the match over the result from classify_keepalive_error
to only handle KeepaliveResult::FatalFailure and
KeepaliveResult::TransientFailure (or replace the Ok arm with a wildcard that
panics/unreachable), referencing the classify_keepalive_error call and the
KeepaliveResult enum so the unreachable KeepaliveResult::Ok arm is removed from
the error-handling 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/keepalive.rs`:
- Around line 91-109: The match in the Err(e) branch calls
classify_keepalive_error(&e) but includes a KeepaliveResult::Ok arm which is
impossible there; update the match over the result from classify_keepalive_error
to only handle KeepaliveResult::FatalFailure and
KeepaliveResult::TransientFailure (or replace the Ok arm with a wildcard that
panics/unreachable), referencing the classify_keepalive_error call and the
KeepaliveResult enum so the unreachable KeepaliveResult::Ok arm is removed from
the error-handling path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b29fbf97-c7c8-44b1-9d10-f10f38144052

📥 Commits

Reviewing files that changed from the base of the PR and between ee10987 and 0b3046d.

📒 Files selected for processing (4)
  • src/client/lifecycle.rs
  • src/client/node_io.rs
  • src/keepalive.rs
  • wacore/src/net.rs

Address two review findings on the disconnect log-classification change so it
never hides a real error behind reconnect noise.

keepalive: the log level was keyed on the FatalFailure classification, which
also covers Socket/EncryptSend/ClientState/EncodeError. Those are fatal for
control flow but mean the socket or send pipeline broke while we still believed
we were connected, so the keepalive may be the first thing to see them. Key the
log level on a narrower is_benign_teardown (NotConnected/Disconnected/channel
closed only) instead, so only an already-gone connection is quiet.

node_io: the event-channel-closed branch carries no DisconnectReason, so we
can't prove it was a clean recycle. Raise it from info to warn to match the
conservative Unknown rule in is_clean_shutdown.

Adds bad/happy path tests for is_benign_teardown.
@jlucaso1
jlucaso1 merged commit 7f1eaa2 into main Jun 8, 2026
10 checks passed
@jlucaso1
jlucaso1 deleted the fix/classify-benign-disconnect-log-level branch June 8, 2026 19:09
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.

1 participant