Skip to content

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

Description

@jqueguiner

Summary

void send --via whatsapp prints Message sent (id: ...) and exits 0 for messages that are never delivered. The returned id is generated locally before transmission, and nothing waits for the server ack, so a send on a dead or dying socket is reported as a success.

This is a silent data-loss bug: the caller has no way to know the message did not leave the machine.

Reproduction (observed in production, 2026-09-11)

void send --via whatsapp --connection WA-french --to "<jid>@lid" --message "..."
Message sent (id: 3EB01E021E254E73BF2930)

Returned in 0.66 s. The message was never delivered. Verified three independent ways, 80 seconds later:

  • absent from WhatsApp's own store ChatStorage.sqlite, read via a WAL-inclusive copy of the .sqlite / -wal / -shm triple, so this is not a read-staleness artifact
  • absent from void's own store: void messages <conversation_id> did not list it
  • no delivery receipt anywhere in the sync log

What the sync log shows:

18:44:25.092  INFO void_whatsapp::connector::ops: sending WhatsApp message via sync connection_id=WA-french recipient_jid=<jid>@lid
18:45:17.977  WARN wa_rs::client: Message loop exited with an error. Will attempt to reconnect if enabled.
18:45:17.977  WARN void_whatsapp::connector::connector_trait: WhatsApp disconnected, waiting for reconnect

For contrast, the same text sent a few minutes later through another path produced the signature of a real send in the same log:

[whatsapp:WA-french] JL - me: <text>
INFO wa_rs::receipt: Received receipt type 'Other("delivery")' for message 3BA4982CACB860074875 from <jid>:13@lid

A healthy send yields a server ack and later delivery receipts. The failed one yielded neither, yet the CLI reported success.

Root cause

The id is generated locally, before transmission, and returned unconditionally.

wa-rs 0.2.0, src/send.rs:40:

pub async fn send_message_with_options(&self, to: Jid, message: wa::Message, options: SendOptions)
    -> Result<String, anyhow::Error> {
    let request_id = self.generate_message_id().await;
    self.send_message_impl(to, &message, Some(request_id.clone()), ...).await?;
    Ok(request_id)          // returned as soon as bytes are handed to the socket
}

send_message_impl ends with self.send_node(stanza_to_send).await, and send_node (src/client.rs:2014) only marshals, encrypts and writes to the noise socket. It returns Ok(()) once the bytes are written. Nobody waits for the server ack stanza. If the socket is dead, or dies mid-write (our case: the message loop exited about 50 s later), the local write succeeds while the stanza never reaches WhatsApp.

On void's side the call site is crates/void-whatsapp/src/connector/ops.rs, send_via_sync, which returns the id upward, and the CLI prints Message sent (id: ...).

Worth noting: the library already has the machinery to wait for a server response. client.response_waiters (src/client.rs:107) is populated and woken on incoming acks (src/client.rs:1313), and src/request.rs:135 shows the register / await / timeout pattern used for IQs. An ack-aware send is expressible with existing primitives.

Expected behaviour

  1. A send that is not acked by the server exits non-zero with a clear error (dead socket, disconnect during send, timeout). An "unknown" outcome is acceptable as long as the wording does not claim success.
  2. A bounded wait for the server ack, with a distinguishable outcome for timeout versus transport error.
  3. A connection-liveness precheck before sending, so a send on a disconnected or reconnecting connection fails fast instead of writing into the void.
  4. No regression on the happy path: a normal send still returns the id promptly.

Impact

Any automation built on void send inherits a false success signal. In our case a time-critical message was reported as sent and was not, and the failure was only caught because we cross-checked ChatStorage.sqlite and the sync log by hand. Anything that retries on failure will also never retry, since it never sees a failure.

Environment: void built from ccb65f6, wa-rs 0.2.0, macOS 26.6.2.

A PR follows.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions