Skip to content

fix(whatsapp): never report a send as sent without server confirmation - #78

Open
jqueguiner wants to merge 1 commit into
MaximeGaudin:mainfrom
jqueguiner:jl/wa-send-ack-upstream
Open

fix(whatsapp): never report a send as sent without server confirmation#78
jqueguiner wants to merge 1 commit into
MaximeGaudin:mainfrom
jqueguiner:jl/wa-send-ack-upstream

Conversation

@jqueguiner

Copy link
Copy Markdown

What

A WhatsApp send can no longer be reported as sent unless the server confirms it.

New module crates/void-whatsapp/src/connector/delivery.rs:

  • precheck(): refuses to write when the connection has no live socket, or has a socket but is not logged in yet (the reconnect-handshake state). Nothing is written, and the error says so.
  • confirm_accepted(): bounded barrier after the write. 12 s deadline, plus a 15 s outer cap because send_iq only bounds the response wait, not the send_node that precedes it.
  • with_send_timeout(): 30 s hard cap on the write itself, which wa-rs does not bound at all (the noise sender task can hang on transport.send).

Wired into all four send paths in ops.rs: send_via_sync, reply_via_sync, and their notes-to-self variants.

How the barrier proves acceptance, since the ack is not reachable (see Why): all outbound frames go through a single NoiseSocket sender task (socket/noise_socket.rs, encrypt_and_send pushes a job onto an mpsc queue and awaits that job's result), so frames hit the TCP stream in call order and the caller does not return until its own frame was handed to the transport. Issuing a w:p ping IQ after the message and waiting for its pong is therefore a stream barrier: a pong proves the server consumed the stream past our message frame.

Outcomes stay distinguishable, and none of them reads as a success:

[precheck]   WhatsApp send aborted on connection 'WA-french': the connection has no live socket (disconnected or reconnecting). Nothing was written to the socket, the message was not sent.
[transport]  WhatsApp send NOT confirmed on connection 'WA-french': the transport failed while confirming message 3EB01E021E254E73BF2930 (Client is not connected). The message very likely never reached WhatsApp.
[timeout]    WhatsApp send NOT confirmed on connection 'WA-french': the stanza for message 3EB01E021E254E73BF2930 was written but the server did not confirm it within 12s (no server response before the deadline). Delivery is unknown, do not assume the message arrived.

The timeout and bad-server-reply cases say delivery is unknown. The transport case says the message very likely never arrived. Neither overclaims.

Why

Root cause confirmed, and it is in wa-rs 0.2.0, read at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/wa-rs-0.2.0/:

  • send.rs:40 send_message_with_options generates the id locally, calls send_message_impl, returns Ok(request_id).
  • send_message_impl ends on self.send_node(stanza_to_send).
  • client.rs:2014 send_node marshals, encrypts, writes, returns Ok(()).

Nothing waits for the server <ack/>. On 2026-09-11 the CLI printed Message sent (id: 3EB01E021E254E73BF2930) in 0.66 s for a message that was in no store afterwards, and the wa-rs message loop exited 52 s later.

An ack-aware send is not expressible from a consumer of wa-rs 0.2.0. Two things block it:

  1. Client::response_waiters is pub(crate) (client.rs:107). send_iq uses it (request.rs:135), but there is no public way to register a waiter for an arbitrary stanza id.
  2. Incoming <ack/> stanzas never reach the public event bus. handlers/basic.rs AckHandler::handle calls client.handle_ack_response(node) and returns true to consume the stanza. handle_ack_response (client.rs:1315) only resolves a response_waiters entry. There is no Event::ServerAck in wa_rs_core::types::events::Event, so a consumer cannot observe the ack for its own send.

Missing upstream API, in order of usefulness:

  • Client::send_message_and_wait_ack(to, message, timeout) -> Result<String, SendError>, returning only after the server <ack/> for that message id.
  • Or an Event::ServerAck { id, class, error: Option<u16> } dispatched from AckHandler before it consumes the stanza, which would let a consumer correlate acks itself.
  • Or a public Client::register_response_waiter(id) -> oneshot::Receiver<Node>.

Event::Receipt is dispatched publicly, but a receipt is the recipient's delivery confirmation, not the server's acceptance. It does not arrive at all while the recipient is offline, so it cannot back a 12 s send verdict.

Given that, this PR takes the documented fallback: liveness precheck plus a bounded post-send verification. The verification is stronger than a store or receipt poll, because the ping barrier is a positive proof that the server consumed our bytes.

Verified

Every command below was redirected to a file, the real exit code echoed, then the file read back.

$ cargo fmt --all -- --check > /tmp/fmt.txt 2>&1; echo "FMT_EXIT=$?"
FMT_EXIT=0

$ cargo build --release > /tmp/v_build.txt 2>&1; echo "BUILD_EXIT=$?"
BUILD_EXIT=0

$ cargo clippy --all-targets > /tmp/v_clippy.txt 2>&1; echo "CLIPPY_EXIT=$?"
CLIPPY_EXIT=0

$ cargo test -p void-whatsapp > /tmp/v_test.txt 2>&1; echo "TEST_EXIT=$?"
TEST_EXIT=0

Tails of those files:

$ tail -2 /tmp/v_build.txt
    Finished `release` profile [optimized] target(s) in 1m 22s

$ tail -3 /tmp/v_clippy.txt
    Checking void-whatsapp v0.11.1 (/Users/jlqueguiner/dev/void/crates/void-whatsapp)
    Checking void-cli v0.11.1 (/Users/jlqueguiner/dev/void/crates/void-cli)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 54.26s

New tests, from /tmp/v_test.txt:

test connector::delivery::tests::liveness_rejects_fully_down_connection ... ok
test connector::delivery::tests::barrier_pong_confirms_the_send ... ok
test connector::delivery::tests::liveness_passes_only_when_connected_and_logged_in ... ok
test connector::delivery::tests::liveness_rejects_dead_socket ... ok
test connector::delivery::tests::liveness_rejects_socket_up_but_not_logged_in ... ok
test connector::delivery::tests::barrier_timeout_is_unconfirmed_not_transport ... ok
test connector::delivery::tests::send_stalled_reports_unknown_delivery ... ok
test connector::delivery::tests::barrier_bad_server_reply_is_unconfirmed ... ok
test connector::delivery::tests::barrier_transport_errors_are_distinguishable_from_timeout ... ok
test connector::delivery::tests::with_send_timeout_passes_through_success ... ok
test connector::delivery::tests::with_send_deadline_fails_when_the_write_hangs ... ok
test connector::tests::a_send_that_returns_an_id_but_is_never_confirmed_is_a_failure ... ok
test connector::tests::barrier_on_dead_socket_reports_transport_failure ... ok
test connector::tests::send_on_dead_socket_fails_instead_of_reporting_success ... ok
test connector::tests::dead_socket_send_fails_fast ... ok
test connector::tests::reply_on_dead_socket_fails_instead_of_reporting_success ... ok

test result: ok. 115 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.17s

The failing case, pinned

connector::tests::a_send_that_returns_an_id_but_is_never_confirmed_is_a_failure is the 2026-09-11 bug reproduced at the seam. The write is stubbed to return Ok("3EB01E021E254E73BF2930"), exactly what wa-rs does for a stanza the server never sees, and the barrier that follows cannot round-trip. The composed result is an error, not an id.

The dead-socket tests build a real wa_rs::Client (real PersistenceManager, real transport factory, in-memory store) that has never connected, then inject it into a real WhatsAppConnector and call the real send_via_sync / reply_via_sync.

Before and after, on the same tests. ops.rs stashed back to its pre-fix state:

$ git stash push -- crates/void-whatsapp/src/connector/ops.rs
$ cargo test -p void-whatsapp dead_socket > /tmp/before.txt 2>&1; echo "EXIT=$?"
EXIT=101

running 5 tests
test connector::delivery::tests::liveness_rejects_dead_socket ... ok
test connector::tests::dead_socket_send_fails_fast ... ok
test connector::tests::barrier_on_dead_socket_reports_transport_failure ... ok
test connector::tests::reply_on_dead_socket_fails_instead_of_reporting_success ... FAILED
test connector::tests::send_on_dead_socket_fails_instead_of_reporting_success ... FAILED

---- connector::tests::send_on_dead_socket_fails_instead_of_reporting_success stdout ----
thread 'connector::tests::send_on_dead_socket_fails_instead_of_reporting_success' panicked at crates/void-whatsapp/src/connector/tests.rs:864:5:
unexpected error: Client is not connected

test result: FAILED. 3 passed; 2 failed; 0 ignored; 0 measured; 110 filtered out; finished in 0.06s

Read that honestly: on a fully dead client the old code already errored, with the opaque Client is not connected from deep inside wa-rs. So the fully-disconnected case is not the production bug, and this PR improves the wording there rather than flipping a success into a failure. The actual behaviour flip is the barrier, and it is the case the production incident hit: the socket looked usable, the write returned Ok(id), the stanza was lost. That path had no check at all before this PR, and is now covered by a_send_that_returns_an_id_but_is_never_confirmed_is_a_failure and barrier_on_dead_socket_reports_transport_failure.

Happy path

Covered by barrier_pong_confirms_the_send (a pong classifies as accepted) and with_send_timeout_passes_through_success (the id passes through untouched). The added cost on a healthy connection is one w:p ping round-trip, the same IQ the keepalive loop already sends every 20 to 30 s.

No live end-to-end send was performed, and no WhatsApp message was sent to anyone during this work. Reason stated below.

Not in this PR

  • No live end-to-end send, not even to notes-to-self. When the sync daemon is running, void send routes through RPC to the daemon (writes.rs:159), so the send executes in the daemon process, not the CLI. Proving this live would mean killing and restarting the running void sync --daemon-inner (PID 76381, up since the previous evening) on the new binary. That is a disruptive change to a live WhatsApp session, it was not asked for, and it could not be confirmed interactively. Restart the daemon on this build and one notes-to-self send will exercise it.
  • No simulation of a live-but-unresponsive socket. That would need a Client with a completed noise handshake and a peer that never answers. wa_rs::transport::mock is #[cfg(test)]-gated upstream, and completing the handshake requires a real server static key, so the full stanza path cannot be faked from void. The barrier is instead covered at the seam and at unit level, every IqError branch included.
  • No upstream patch to wa-rs. The crate is consumed from crates.io and was treated as read-only. The missing API is written up under Why, ready to become an upstream issue.
  • No retry or outbox on an unconfirmed send. Retrying a stanza that may already have been accepted risks a duplicate message. Reporting the unknown honestly is the correct first step, and a retry needs the real ack to be safe.

Upstream rebase check

This branch is origin/main (add6674) plus this one commit, nothing else. Re-verified on that base, not only on the local build:

$ cd /tmp/void_up && cargo test -p void-whatsapp > /tmp/up_test.txt 2>&1; echo "TEST_EXIT=$?"
TEST_EXIT=0
test result: ok. 115 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.18s

Closes #77

wa-rs 0.2 returns a message id as soon as the stanza bytes are written to
the noise socket. Nothing waits for the server ack, so a send on a dying
socket returns Ok(id) and the CLI prints "Message sent" for a message the
server never saw. Measured in production on 2026-09-11.

The library's ack machinery is not reachable from a consumer:
Client::response_waiters is pub(crate), and incoming <ack/> stanzas are
consumed by handlers::basic::AckHandler without being dispatched on the
public event bus (there is no Event::ServerAck).

So confirm acceptance with stream ordering instead. All outbound frames go
through one NoiseSocket sender task, so frames hit the TCP stream in call
order. After the message, issue a w:p ping IQ and wait, bounded, for its
pong: a pong proves the server consumed the stream past our message frame.

Adds crates/void-whatsapp/src/connector/delivery.rs:
- precheck(): fails fast when the connection has no live socket or is not
  logged in, before anything is written,
- confirm_accepted(): 12s bounded barrier after the write, with timeout,
  bad-server-reply and transport outcomes kept distinguishable,
- with_send_timeout(): hard cap on the write itself, which wa-rs does not
  bound.

Wired into all four send paths in ops.rs (send, reply, and their
notes-to-self variants). No error variant can be read as a success, and
the two unknown cases say delivery is unknown rather than claiming loss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jqueguiner

Copy link
Copy Markdown
Author

Live end-to-end verification on the fixed build

Ran against a real WhatsApp session after installing this build and restarting the sync daemon, since void send routes through the daemon when it is up. Target was the author's own notes-to-self chat, no third party involved.

Daemon restarted on the new binary:

$ void sync --daemon --restart
Stopping sync daemon (pid 76381)...
Sync daemon stopped.
Starting sync daemon... logs at ~/.local/share/void/void-sync.log
Sync daemon started (pid 38756).

INFO wa_rs::client: Successfully authenticated with WhatsApp servers! (gen=1)
INFO void_whatsapp::connector::connector_trait: WhatsApp connected

Happy path, send to self:

$ void send --via whatsapp --connection WA-french --to "<self>@s.whatsapp.net" --message "test void ack barrier, PR 78 - ignore"
Message sent (id: 3EB04ED91023159A103E91)
RC=0

Returned in 0.5 s, so the barrier does not slow the happy path. Confirmed delivered, not just claimed. Server receipts in the sync log:

INFO wa_rs::receipt: Received receipt type 'Sender' for message 3EB04ED91023159A103E91 from <self>:23@lid
INFO wa_rs::receipt: Received receipt type 'Sender' for message 3EB04ED91023159A103E91 from <self>@lid

And present in WhatsApp's own store, read through a WAL-inclusive copy, with the id matching what the CLI printed:

ZISFROMME=1  19:17:34 UTC  'test void ack barrier, PR 78 - ignore'  3EB04ED91023159A103E91

That id match is the point: before this PR the printed id proved nothing, because it was minted locally regardless of what the server did.

One caveat worth recording

I tried to reproduce the original failure by stopping the daemon and sending. That does not exercise the barrier: with no daemon, the CLI opens its own connection (WhatsApp connected for send) and the send genuinely succeeds. So a stopped daemon is not a dead-socket simulation. The dead-socket and timeout paths stay covered by the unit tests in connector/tests.rs, in particular a_send_that_returns_an_id_but_is_never_confirmed_is_a_failure, which stubs the write to return Ok("3EB01E021E254E73BF2930") exactly as wa-rs does while the barrier cannot round-trip.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(whatsapp): send reports success for messages that were never delivered

1 participant