-
-
Notifications
You must be signed in to change notification settings - Fork 125
perf: zero-copy frame send, direct prekey encoding, covariant AttrsRef #552
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
6878d77
42a9f5a
d830750
ee2de60
9be72ba
dddd0ee
2cd04f0
5b7c6b2
e466a67
1611d62
edf8a6c
6eee41f
cee2fe8
99d05c4
9e268ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -212,9 +212,78 @@ impl Client { | |
| where | ||
| S: wacore::iq::spec::IqSpec, | ||
| { | ||
| // Try the direct-encode fast path to avoid intermediate Node allocations. | ||
| // Only allocate the buffer if the spec actually uses it. | ||
| let req_id = self.generate_request_id(); | ||
| { | ||
| let mut buf = Vec::new(); | ||
| if let Ok(true) = spec.encode_iq_direct(&req_id, &mut buf) { | ||
| let response = self.send_iq_raw(req_id, buf).await?; | ||
| return spec | ||
| .parse_response(response.get()) | ||
| .map_err(IqError::ParseError); | ||
| } | ||
| } | ||
|
|
||
| let iq = spec.build_iq(); | ||
| let response = self.send_iq(iq).await?; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| spec.parse_response(response.get()) | ||
| .map_err(IqError::ParseError) | ||
| } | ||
|
|
||
| /// Send pre-encoded IQ bytes and wait for the response. | ||
| async fn send_iq_raw( | ||
| &self, | ||
| req_id: String, | ||
| buf: Vec<u8>, | ||
| ) -> Result<Arc<wacore_binary::OwnedNodeRef>, IqError> { | ||
| if !self.is_running.load(Ordering::Relaxed) { | ||
| return Err(IqError::NotConnected); | ||
| } | ||
|
|
||
| let default_timeout = Duration::from_secs(75); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Hardcoded timeout limits flexibility for future specs. The 75-second default matches 🤖 Prompt for AI Agents |
||
|
|
||
| let (tx, rx) = futures::channel::oneshot::channel(); | ||
| self.response_waiters | ||
| .lock() | ||
| .await | ||
| .insert(req_id.clone(), tx); | ||
|
|
||
| let shutdown = self.shutdown_notifier.listen(); | ||
|
|
||
| if !self.is_running.load(Ordering::Acquire) { | ||
| self.response_waiters.lock().await.remove(&req_id); | ||
| return Err(IqError::NotConnected); | ||
| } | ||
|
|
||
| if let Err(e) = self.send_raw_bytes(buf).await { | ||
| self.response_waiters.lock().await.remove(&req_id); | ||
| return match e { | ||
| crate::client::ClientError::Socket(s_err) => Err(IqError::Socket(s_err)), | ||
| crate::client::ClientError::NotConnected => Err(IqError::NotConnected), | ||
| _ => Err(IqError::Socket(SocketError::Crypto(e.to_string()))), | ||
| }; | ||
| } | ||
|
|
||
| let request_utils = self.get_request_utils(); | ||
| futures::select! { | ||
| result = rt_timeout(&*self.runtime, default_timeout, rx).fuse() => { | ||
| match result { | ||
| Ok(Ok(response_node)) => match request_utils.parse_iq_response(response_node.get()) { | ||
| Ok(()) => Ok(response_node), | ||
| Err(e) => Err(e.into()), | ||
| }, | ||
| Ok(Err(_)) => Err(IqError::InternalChannelClosed), | ||
| Err(_) => { | ||
| self.response_waiters.lock().await.remove(&req_id); | ||
| Err(IqError::Timeout) | ||
| } | ||
| } | ||
| } | ||
| _ = shutdown.fuse() => { | ||
| self.response_waiters.lock().await.remove(&req_id); | ||
| Err(IqError::NotConnected) | ||
| } | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -78,9 +78,10 @@ impl NoiseSocket { | |
| send_job_rx: async_channel::Receiver<SendJob>, | ||
| ) { | ||
| let mut write_counter: u32 = 0; | ||
| // Reusable buffers -- capacity stays allocated between sends | ||
| let mut enc_buf = Vec::with_capacity(4096); | ||
| let mut out_buf = Vec::with_capacity(4096); | ||
| // BytesMut: split().freeze() yields a zero-copy Bytes while retaining | ||
| // the underlying allocation for the next frame. | ||
| let mut out_buf = BytesMut::with_capacity(4096); | ||
|
|
||
| while let Ok(job) = send_job_rx.recv().await { | ||
| let result = Self::process_send_job( | ||
|
|
@@ -106,24 +107,21 @@ impl NoiseSocket { | |
| write_counter: &mut u32, | ||
| plaintext: &[u8], | ||
| enc_buf: &mut Vec<u8>, | ||
| out_buf: &mut Vec<u8>, | ||
| out_buf: &mut BytesMut, | ||
| ) -> SendResult { | ||
| let counter = *write_counter; | ||
|
|
||
| if plaintext.len() <= INLINE_ENCRYPT_THRESHOLD { | ||
| // Copy into reusable enc_buf, encrypt in place | ||
| enc_buf.clear(); | ||
| enc_buf.extend_from_slice(plaintext); | ||
| if let Err(e) = write_key.encrypt_in_place_with_counter(counter, enc_buf) { | ||
| return Err(EncryptSendError::crypto(anyhow::anyhow!(e.to_string()))); | ||
| } | ||
|
|
||
| out_buf.clear(); | ||
| if let Err(e) = wacore::framing::encode_frame_into(enc_buf, None, out_buf) { | ||
| return Err(EncryptSendError::framing(e)); | ||
| } | ||
| } else { | ||
| // Large messages: encrypt on blocking thread (reads plaintext, returns new ciphertext) | ||
| let write_key = write_key.clone(); | ||
| let plaintext_owned = plaintext.to_vec(); | ||
|
|
||
|
|
@@ -139,15 +137,15 @@ impl NoiseSocket { | |
| } | ||
| }; | ||
|
|
||
| out_buf.clear(); | ||
| if let Err(e) = wacore::framing::encode_frame_into(&ciphertext, None, out_buf) { | ||
| return Err(EncryptSendError::framing(e)); | ||
| } | ||
| } | ||
|
|
||
| // copy_from_slice so out_buf retains its capacity for the next send | ||
| let frame = bytes::Bytes::copy_from_slice(out_buf); | ||
| out_buf.clear(); | ||
| // Zero-copy: split() moves the written data into a new BytesMut, | ||
| // freeze() converts it to Bytes. The original out_buf retains its | ||
| // allocated capacity for the next frame. | ||
| let frame = out_buf.split().freeze(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Using Useful? React with 👍 / 👎. |
||
| if let Err(e) = transport.send(frame).await { | ||
| return Err(EncryptSendError::transport(e)); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -383,7 +383,106 @@ impl FromIterator<(Cow<'static, str>, NodeValue)> for Attrs { | |
| Self(iter.into_iter().collect()) | ||
| } | ||
| } | ||
| pub type AttrsRef<'a> = Vec<(NodeStr<'a>, ValueRef<'a>)>; | ||
| /// Covariant inline container for decoded node attributes. | ||
| /// Most WA protocol nodes have 0-1 attributes; storing them inline avoids | ||
| /// a heap allocation for the common case. Covariant in `'a` (unlike SmallVec) | ||
| /// so it works with yoke::Yokeable. | ||
| #[derive(Debug, Clone, PartialEq)] | ||
| pub enum AttrsRef<'a> { | ||
| Empty, | ||
| One((NodeStr<'a>, ValueRef<'a>)), | ||
| Many(Vec<(NodeStr<'a>, ValueRef<'a>)>), | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| impl<'a> AttrsRef<'a> { | ||
| pub fn with_capacity(n: usize) -> Self { | ||
| match n { | ||
| 0 => Self::Empty, | ||
| // 1 attr: start Empty, push() will create One (no heap alloc) | ||
| 1 => Self::Empty, | ||
| _ => Self::Many(Vec::with_capacity(n)), | ||
| } | ||
| } | ||
|
|
||
| pub fn push(&mut self, item: (NodeStr<'a>, ValueRef<'a>)) { | ||
| match self { | ||
| Self::Empty => *self = Self::One(item), | ||
| Self::One(_) => { | ||
| let Self::One(prev) = std::mem::replace(self, Self::Empty) else { | ||
| unreachable!() | ||
| }; | ||
| *self = Self::Many(vec![prev, item]); | ||
| } | ||
| Self::Many(v) => v.push(item), | ||
| } | ||
| } | ||
|
|
||
| #[inline] | ||
| pub fn len(&self) -> usize { | ||
| match self { | ||
| Self::Empty => 0, | ||
| Self::One(_) => 1, | ||
| Self::Many(v) => v.len(), | ||
| } | ||
| } | ||
|
|
||
| #[inline] | ||
| pub fn is_empty(&self) -> bool { | ||
| matches!(self, Self::Empty) | ||
| } | ||
|
|
||
| #[inline] | ||
| pub fn as_slice(&self) -> &[(NodeStr<'a>, ValueRef<'a>)] { | ||
| match self { | ||
| Self::Empty => &[], | ||
| Self::One(item) => std::slice::from_ref(item), | ||
| Self::Many(v) => v.as_slice(), | ||
| } | ||
| } | ||
|
|
||
| #[inline] | ||
| pub fn iter(&self) -> impl Iterator<Item = &(NodeStr<'a>, ValueRef<'a>)> { | ||
| self.as_slice().iter() | ||
| } | ||
| } | ||
|
|
||
| impl<'a> FromIterator<(NodeStr<'a>, ValueRef<'a>)> for AttrsRef<'a> { | ||
| fn from_iter<I: IntoIterator<Item = (NodeStr<'a>, ValueRef<'a>)>>(iter: I) -> Self { | ||
| let mut result = Self::Empty; | ||
| for item in iter { | ||
| result.push(item); | ||
| } | ||
| result | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // Safety: AttrsRef is covariant in 'a because: | ||
| // - Empty has no lifetime | ||
| // - One contains (NodeStr<'a>, ValueRef<'a>) which are covariant | ||
| // - Many(Vec<T>) is covariant in T | ||
| // yoke::Yokeable requires covariance, which Vec and tuples provide. | ||
| unsafe impl<'a> yoke::Yokeable<'a> for AttrsRef<'static> { | ||
| type Output = AttrsRef<'a>; | ||
|
|
||
| fn transform(&'a self) -> &'a Self::Output { | ||
| self | ||
| } | ||
|
|
||
| fn transform_owned(self) -> Self::Output { | ||
| self | ||
| } | ||
|
|
||
| unsafe fn make(from: Self::Output) -> Self { | ||
| unsafe { std::mem::transmute(from) } | ||
| } | ||
|
|
||
| fn transform_mut<F>(&'a mut self, f: F) | ||
| where | ||
| F: 'static + for<'b> FnOnce(&'b mut Self::Output), | ||
| { | ||
| unsafe { f(std::mem::transmute::<&mut Self, &mut Self::Output>(self)) } | ||
| } | ||
| } | ||
|
Comment on lines
+460
to
+481
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Unsafe The safety argument relies on
The 🤖 Prompt for AI Agents |
||
|
|
||
| /// A decoded attribute value that can be either a string or a structured JID. | ||
| /// This avoids string allocation when decoding JID tokens - the JidRef is returned | ||
|
|
@@ -615,7 +714,7 @@ struct AttrsRefWrapper<'a, 'b>(&'b AttrsRef<'a>); | |
| #[cfg(feature = "serde")] | ||
| impl serde::Serialize for AttrsRefWrapper<'_, '_> { | ||
| fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { | ||
| serializer.serialize_newtype_struct("Attrs", self.0) | ||
| serializer.serialize_newtype_struct("Attrs", self.0.as_slice()) | ||
| } | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick | 🔵 Trivial
Consider pre-allocating the buffer for the fast path.
Vec::new()starts at zero capacity and grows during encoding. Since prekey IQs are typically 1-4KB, a pre-sized buffer could avoid reallocations.♻️ Optional optimization
🤖 Prompt for AI Agents