Skip to content
43 changes: 38 additions & 5 deletions src/client/messaging.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,54 @@ impl Client {
Ok(())
}

/// Send several pre-marshaled stanzas as one burst, returning a result per
/// stanza in the order given.
///
/// The noise sender coalesces whatever is queued when it wakes, but a
/// worker that awaits each send before starting the next never has two
/// frames queued at once, so the coalescing it was built for never fires.
/// Handing over the whole burst is what turns batching from incidental into
/// the normal case.
///
/// Order is preserved, which the ack worker depends on. The socket is
/// resolved once up front, so the only await left inside each send is the
/// channel push, and a channel with room resolves that on its first poll:
/// polling the joined sends in order therefore queues them in order.
/// Resolving the socket per send instead would put a contended mutex
/// between the futures and let them queue in any order.
pub(crate) async fn send_raw_bytes_burst(
&self,
frames: Vec<Vec<u8>>,
) -> Result<Vec<crate::socket::error::EncryptSendResult>, ClientError> {
let noise_socket = self.get_noise_socket().await?;
let sends = frames
.into_iter()
.map(|plaintext| noise_socket.encrypt_and_send(bytes::Bytes::from(plaintext)));
Ok(futures::future::join_all(sends).await)
}

#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.send.node", level = "debug", skip_all, fields(tag = %node.tag), err(Debug)))]
pub async fn send_node(&self, node: Node) -> Result<(), ClientError> {
let plaintext_buf = self.marshal_node_for_send(node)?;
self.send_raw_bytes(plaintext_buf).await
}

/// Everything [`send_node`](Client::send_node) does short of the send:
/// logging, waiter resolution and marshalling. Split out so a burst can
/// marshal its whole batch before touching the socket, which is what keeps
/// the sends orderable.
pub(crate) fn marshal_node_for_send(&self, node: Node) -> Result<Vec<u8>, ClientError> {
debug!(target: "Client/Send", "{}", DisplayableNode(&node));
if self.sent_node_waiter_count.load(Ordering::Acquire) > 0 {
self.resolve_sent_node_waiters(&Arc::new(node.clone()));
}

// Exact two-pass sizing: typical stanzas are a few hundred bytes, so
// the 1 KiB default reserve of the one-pass path mostly over-allocates.
let plaintext_buf = wacore_binary::marshal::marshal_exact(&node).map_err(|e| {
wacore_binary::marshal::marshal_exact(&node).map_err(|e| {
error!("Failed to marshal node: {e:?}");
SocketError::Marshal(e)
})?;

self.send_raw_bytes(plaintext_buf).await
SocketError::Marshal(e).into()
})
}

#[cfg_attr(
Expand Down
68 changes: 63 additions & 5 deletions src/client/node_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,17 @@ impl Client {
let _ = tx.try_send((node, guard));
}

/// How many queued acks one burst may take.
///
/// Measured, not guessed: the send-job channel holds 8, so a larger burst
/// fills it and makes unrelated producers (a reply, a receipt) wait for a
/// slot. At 16 the harness showed 29% fewer writes but 3.7% worse pong
/// latency (paired t = 2.8); at 4 the write saving is ~16% and latency is
/// no worse than main. Raising the channel instead recovers the latency but
/// gives back most of the coalescing, because a sender that never waits
/// consumes jobs one at a time.
const MAX_ACK_BURST: usize = 4;

/// Worker shared by every deferred ack. Holds a `Weak`, so a dropped
/// `Client` closes the channel and ends the task instead of keeping the
/// client alive.
Expand All @@ -667,16 +678,63 @@ impl Client {
let client = Arc::downgrade(self);
self.runtime
.spawn(Box::pin(async move {
while let Ok((node, guard)) = rx.recv().await {
while let Ok(first) = rx.recv().await {
let Some(client) = client.upgrade() else {
break;
};
if let Err(e) = client.send_ack_for(node.get()).await
&& !e.is_transport_unavailable()

// Take everything already waiting, not just the one job that
// woke us. Awaiting each ack before reading the next is what
// kept the noise sender from ever seeing two frames at once,
// so its batching only fired when some *other* producer
// happened to interleave. `try_recv` only: this never waits
// for work that has not arrived.
let mut batch = vec![first];
while batch.len() < Self::MAX_ACK_BURST
&& let Ok(next) = rx.try_recv()
{
warn!("Failed to send ack: {e:?}");
batch.push(next);
}

// Encoding is synchronous, so the whole burst is marshalled
// before anything is sent and arrival order survives.
let mut frames = Vec::with_capacity(batch.len());
let mut guards = Vec::with_capacity(batch.len());
for (node, guard) in batch {
match client.encode_ack_from_snapshot(
node.get(),
AckParticipantPolicy::OmitReceiptDestinationDuplicate,
) {
Ok(buf) => {
frames.push(buf);
guards.push(guard);
}
// Matches the single-ack path: log and drop this one
// rather than failing the rest of the burst.
Err(e) => warn!("Failed to encode ack: {e}"),
}
}
if frames.is_empty() {
continue;
}

match client.send_raw_bytes_burst(frames).await {
Ok(results) => {
for result in results {
if let Err(e) = result
&& !e.is_transport_unavailable()
{
warn!("Failed to send ack: {e:?}");
}
}
}
Err(e) => {
if !matches!(e, ClientError::NotConnected) {
warn!("Failed to send ack burst: {e:?}");
}
}
}
drop(guard);
drop(guards);
}
}))
.detach();
Expand Down
51 changes: 48 additions & 3 deletions src/message/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ impl Client {
let _ = tx.try_send((Arc::clone(info), guard));
}

/// How many queued receipts one burst may take; mirrors the ack worker's
/// [`MAX_ACK_BURST`](Client::MAX_ACK_BURST), where the tradeoff is measured.
const MAX_RECEIPT_BURST: usize = 4;

/// Worker task shared by every live delivery receipt. Holds only a `Weak`
/// so a dropped `Client` closes the channel and ends the task instead of
/// keeping the client alive.
Expand All @@ -138,12 +142,53 @@ impl Client {
let client = Arc::downgrade(self);
self.runtime
.spawn(Box::pin(async move {
while let Ok((info, guard)) = rx.recv().await {
while let Ok(first) = rx.recv().await {
let Some(client) = client.upgrade() else {
break;
};
client.send_delivery_receipt(&info).await;
drop(guard);

// Same reasoning as the ack worker: awaiting each receipt
// before reading the next means the noise sender never has
// two frames to coalesce. `try_recv` only, so nothing waits
// on work that has not arrived.
let mut batch = vec![first];
while batch.len() < Self::MAX_RECEIPT_BURST
&& let Ok(next) = rx.try_recv()
{
batch.push(next);
}

let mut frames = Vec::with_capacity(batch.len());
let mut guards = Vec::with_capacity(batch.len());
for (info, guard) in batch {
// Building the node is synchronous, so the burst is
// fully prepared before anything reaches the socket.
if let Some(frame) = client.prepare_delivery_receipt(&info) {
frames.push(frame);
guards.push(guard);
}
}
if frames.is_empty() {
continue;
}

match client.send_raw_bytes_burst(frames).await {
Ok(results) => {
for result in results {
if let Err(e) = result
&& !e.is_transport_unavailable()
{
log::warn!(target: "Client/Receipt", "Failed to send delivery receipt: {e:?}");
}
}
}
Err(e) => {
if !matches!(e, crate::client::ClientError::NotConnected) {
log::warn!(target: "Client/Receipt", "Failed to send delivery receipt burst: {e:?}");
}
}
}
drop(guards);
}
}))
.detach();
Expand Down
27 changes: 21 additions & 6 deletions src/receipt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,8 +591,23 @@ impl Client {
/// handled by the ack gate, not here).
#[cfg_attr(feature = "tracing", tracing::instrument(name = "wa.receipt.send_delivery", level = "debug", skip_all, fields(chat = %info.source.chat.observe(), sender = %info.source.sender.observe(), msg_id = %info.id)))]
pub(crate) async fn send_delivery_receipt(&self, info: &MessageInfo) {
if !Self::should_send_delivery_receipt(info) {
let Some(frame) = self.prepare_delivery_receipt(info) else {
return;
};
if let Err(e) = self.send_raw_bytes(frame).await
&& !matches!(e, crate::client::ClientError::NotConnected)
{
log::warn!(target: "Client/Receipt", "Failed to send delivery receipt for message {}: {:?}", info.id, e);
}
}

/// Everything [`Self::send_delivery_receipt`] does short of the send: the
/// eligibility gate, node construction, logging and marshalling. Returns
/// `None` when no receipt is owed. Split out so the receipt worker can
/// prepare a whole burst before touching the socket.
pub(crate) fn prepare_delivery_receipt(&self, info: &MessageInfo) -> Option<Vec<u8>> {
if !Self::should_send_delivery_receipt(info) {
return None;
}

let receipt_node = build_delivery_receipt_node(info, self.receipts_are_active());
Expand All @@ -611,11 +626,11 @@ impl Client {
debug!(target: "Client/Receipt", "Sending {} receipt for message {} to {}",
receipt_kind.as_wire_str(), info.id, info.source.sender.observe());

if let Err(e) = self.send_node(receipt_node).await
&& !matches!(e, crate::client::ClientError::NotConnected)
{
log::warn!(target: "Client/Receipt", "Failed to send delivery receipt for message {}: {:?}", info.id, e);
}
self.marshal_node_for_send(receipt_node)
.inspect_err(|e| {
log::warn!(target: "Client/Receipt", "Failed to marshal delivery receipt for message {}: {:?}", info.id, e);
})
.ok()
}

/// Buffer an offline-drained message's delivery receipt for the aggregate
Expand Down
3 changes: 3 additions & 0 deletions src/socket/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ pub enum SocketError {

pub type Result<T> = std::result::Result<T, SocketError>;

/// Outcome of one frame's trip through the noise sender.
pub type EncryptSendResult = std::result::Result<(), EncryptSendError>;

#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum EncryptSendErrorKind {
Expand Down
92 changes: 77 additions & 15 deletions src/socket/noise_socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,21 +259,34 @@ impl NoiseSocket {
}

// Every frame in this batch shares the fate of the single write.
// EncryptSendError carries an anyhow::Error and is not Clone, and
// its Display renders only the kind, so flattening the failure to a
// string would hand every caller "transport error" with the actual
// cause discarded. Sharing one Arc keeps the whole chain intact for
// all of them at the cost of a refcount bump each.
let failure = match outcome {
Ok(()) => None,
Err(err) => Some(Arc::new(err)),
};
for (response_tx, _) in waiters.drain(..) {
let result = match &failure {
None => Ok(()),
Some(err) => Err(EncryptSendError::transport(SharedSendFailure(err.clone()))),
};
let _ = response_tx.send(result);
match outcome {
Ok(()) => {
for (response_tx, _) in waiters.drain(..) {
let _ = response_tx.send(Ok(()));
}
}
// One waiter owns the failure outright. This is the overwhelmingly
// common case, and handing over the error untouched is what keeps
// `err.source.downcast_ref::<MyTransportError>()` working for a
// caller with its own Transport: wrapping would bury the typed
// error one level down for no benefit, since there is nobody to
// share it with.
Err(err) if waiters.len() == 1 => {
let (response_tx, _) = waiters.drain(..).next().expect("length checked");
let _ = response_tx.send(Err(err));
}
// Several waiters, and EncryptSendError is not Clone: they share
// one Arc. Display renders only the kind, so re-wording per waiter
// would hand each caller "transport error" with the cause gone;
// sharing keeps the whole chain reachable for a refcount bump each.
Err(err) => {
let shared = Arc::new(err);
for (response_tx, _) in waiters.drain(..) {
let _ = response_tx.send(Err(EncryptSendError::transport(
SharedSendFailure(shared.clone()),
)));
}
}
}
if let Some((response_tx, err)) = encrypt_failure {
let _ = response_tx.send(Err(err));
Expand Down Expand Up @@ -674,6 +687,55 @@ mod tests {
}
}

/// A single-frame send hands its caller the transport's own error, not a
/// wrapper. Callers with a custom `Transport` downcast to their own error
/// type to decide whether a failure is retryable, and `downcast_ref` looks
/// at the concrete type rather than walking the chain, so wrapping the
/// common case would silently break that.
#[tokio::test]
async fn a_lone_waiter_gets_the_transport_error_untouched() {
#[derive(Debug)]
struct TypedTransportError;
impl std::fmt::Display for TypedTransportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "typed transport error")
}
}
impl std::error::Error for TypedTransportError {}

struct TypedFailTransport;

#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl Transport for TypedFailTransport {
async fn send(&self, _data: bytes::Bytes) -> std::result::Result<(), anyhow::Error> {
Err(anyhow::Error::new(TypedTransportError))
}
async fn disconnect(&self) {}
}

let key = [0x77u8; 32];
let runtime: Arc<dyn Runtime> = Arc::new(crate::runtime_impl::TokioRuntime);
let socket = NoiseSocket::new(
runtime,
Arc::new(TypedFailTransport),
NoiseCipher::new(&key).expect("32-byte key"),
NoiseCipher::new(&key).expect("32-byte key"),
);

let err = socket
.encrypt_and_send(bytes::Bytes::from(vec![9u8; 32]))
.await
.expect_err("the transport always fails");

assert!(matches!(err.kind, EncryptSendErrorKind::Transport));
assert!(
err.source.downcast_ref::<TypedTransportError>().is_some(),
"a lone waiter must receive the transport's own error type, got: {:?}",
err.source
);
}

/// The byte ceiling must hold across a burst. Checking it after appending
/// would let a nearly-full batch overshoot by a whole frame, which for a
/// large stanza is the difference between a bounded buffer and an unbounded
Expand Down
Loading
Loading