diff --git a/crates/wire/src/envelope.rs b/crates/wire/src/envelope.rs index a501e633..e3901bd7 100644 --- a/crates/wire/src/envelope.rs +++ b/crates/wire/src/envelope.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; +mod response_decode; + use crate::error::ApiError; use crate::request::ClientRequest; use crate::response::DaemonResponse; @@ -47,7 +49,7 @@ pub struct RequestEnvelope { } /// One daemon response correlating to a request ID. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ResponseEnvelope { pub id: Option, #[serde(flatten)] diff --git a/crates/wire/src/envelope/response_decode.rs b/crates/wire/src/envelope/response_decode.rs new file mode 100644 index 00000000..ab1b31b1 --- /dev/null +++ b/crates/wire/src/envelope/response_decode.rs @@ -0,0 +1,256 @@ +//! Response deserialization without the outer flattened Content copy. + +use serde::{Deserialize, Deserializer}; + +use super::{RequestId, ResponseEnvelope, ResponseResult}; +use crate::{ApiError, DaemonResponse}; + +impl<'de> Deserialize<'de> for ResponseEnvelope { + fn deserialize>(deserializer: D) -> Result { + // Match the original flattened derive's root entry point. + deserializer.deserialize_map(ResponseVisitor) + } +} +struct ResponseVisitor; +impl<'de> serde::de::Visitor<'de> for ResponseVisitor { + type Value = ResponseEnvelope; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("struct ResponseEnvelope") + } + fn visit_map>(self, inner: A) -> Result { + let decoded = Decoded::deserialize(serde::de::value::MapAccessDeserializer::new( + ResponseFields { + inner, + field: FieldKind::Other, + }, + ))?; + Ok(match decoded { + Decoded::Ok { id, payload } => ResponseEnvelope { + id, + result: ResponseResult::Ok { payload }, + }, + Decoded::Error { id, error } => ResponseEnvelope { + id, + result: ResponseResult::Error { error }, + }, + }) + } +} + +// Decode the correlation id inside each status branch. Keeping it beside a +// flattened ResponseResult buffers and clones the entire payload an extra time. +#[derive(Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum Decoded { + Ok { + id: Option, + #[serde(flatten)] + payload: Box, + }, + Error { + id: Option, + #[serde(flatten)] + error: ApiError, + }, +} +#[derive(Clone, Copy)] +enum FieldKind { + Other, + Status, + Id, +} +impl FieldKind { + fn text(value: &str) -> Self { + match value { + "status" => Self::Status, + "id" => Self::Id, + _ => Self::Other, + } + } + fn bytes(value: &[u8]) -> Self { + match value { + b"status" => Self::Status, + b"id" => Self::Id, + _ => Self::Other, + } + } +} +struct ResponseFields { + inner: A, + field: FieldKind, +} +impl<'de, A: serde::de::MapAccess<'de>> serde::de::MapAccess<'de> for ResponseFields { + type Error = A::Error; + fn next_key_seed>( + &mut self, + seed: K, + ) -> Result, A::Error> { + self.field = FieldKind::Other; + self.inner.next_key_seed(KeySeed { + seed, + field: &mut self.field, + }) + } + fn next_value_seed>( + &mut self, + seed: V, + ) -> Result { + match self.field { + FieldKind::Status => self.inner.next_value_seed(StatusSeed(seed)), + FieldKind::Id => self.inner.next_value_seed(IdSeed(seed)), + FieldKind::Other => self.inner.next_value_seed(seed), + } + } + fn size_hint(&self) -> Option { + self.inner.size_hint() + } +} +// Observe tag names without consuming or converting other key types. Generic +// Serde maps may contain unknown numeric, byte, sequence or map keys; the old +// flattened decoder allowed downstream visitors to ignore those keys. +struct KeySeed<'a, S> { + seed: S, + field: &'a mut FieldKind, +} +impl<'de, S: serde::de::DeserializeSeed<'de>> serde::de::DeserializeSeed<'de> for KeySeed<'_, S> { + type Value = S::Value; + fn deserialize>(self, inner: D) -> Result { + self.seed.deserialize(KeyDeserializer { + inner, + field: self.field, + }) + } +} +struct KeyDeserializer<'a, D> { + inner: D, + field: &'a mut FieldKind, +} +impl<'de, D: Deserializer<'de>> Deserializer<'de> for KeyDeserializer<'_, D> { + type Error = D::Error; + fn is_human_readable(&self) -> bool { + self.inner.is_human_readable() + } + fn deserialize_any>(self, inner: V) -> Result { + self.inner.deserialize_identifier(KeyVisitor { + inner, + field: self.field, + }) + } + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf + option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum identifier ignored_any + } +} +struct KeyVisitor<'a, V> { + inner: V, + field: &'a mut FieldKind, +} +macro_rules! forward_key_values { + ($($method:ident($ty:ty)),* $(,)?) => {$( + fn $method(self, value: $ty) -> Result { + self.inner.$method(value) + } + )*}; +} +impl<'de, V: serde::de::Visitor<'de>> serde::de::Visitor<'de> for KeyVisitor<'_, V> { + type Value = V::Value; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + self.inner.expecting(f) + } + forward_key_values! { visit_bool(bool), visit_i8(i8), visit_i16(i16), visit_i32(i32), visit_i64(i64), visit_i128(i128), visit_u8(u8), visit_u16(u16), visit_u32(u32), visit_u64(u64), visit_u128(u128), visit_f32(f32), visit_f64(f64), visit_char(char) } + fn visit_str(self, value: &str) -> Result { + *self.field = FieldKind::text(value); + self.inner.visit_str(value) + } + fn visit_borrowed_str(self, value: &'de str) -> Result { + *self.field = FieldKind::text(value); + self.inner.visit_borrowed_str(value) + } + fn visit_string(self, value: String) -> Result { + *self.field = FieldKind::text(&value); + self.inner.visit_string(value) + } + fn visit_bytes(self, value: &[u8]) -> Result { + *self.field = FieldKind::bytes(value); + self.inner.visit_bytes(value) + } + fn visit_borrowed_bytes(self, value: &'de [u8]) -> Result { + *self.field = FieldKind::bytes(value); + self.inner.visit_borrowed_bytes(value) + } + fn visit_byte_buf(self, value: Vec) -> Result { + *self.field = FieldKind::bytes(&value); + self.inner.visit_byte_buf(value) + } + fn visit_none(self) -> Result { + self.inner.visit_none() + } + fn visit_unit(self) -> Result { + self.inner.visit_unit() + } + fn visit_some>(self, value: D) -> Result { + self.inner.visit_some(value) + } + fn visit_newtype_struct>(self, value: D) -> Result { + self.inner.visit_newtype_struct(value) + } + fn visit_seq>(self, value: A) -> Result { + self.inner.visit_seq(value) + } + fn visit_map>(self, value: A) -> Result { + self.inner.visit_map(value) + } + fn visit_enum>(self, value: A) -> Result { + self.inner.visit_enum(value) + } +} +// Keep the original typed Option hint for id. Some generic formats +// implement typed numeric/option handling differently from deserialize_any. +// Replay the decoded scalar into the helper's buffered representation. +struct IdSeed(S); +impl<'de, S: serde::de::DeserializeSeed<'de>> serde::de::DeserializeSeed<'de> for IdSeed { + type Value = S::Value; + fn deserialize>(self, deserializer: D) -> Result { + match Option::::deserialize(deserializer)? { + Some(id) => self + .0 + .deserialize(serde::de::value::U64Deserializer::::new(id)), + None => self + .0 + .deserialize(serde::de::value::UnitDeserializer::::new()), + } + } +} +struct StatusSeed(S); +impl<'de, S: serde::de::DeserializeSeed<'de>> serde::de::DeserializeSeed<'de> for StatusSeed { + type Value = S::Value; + fn deserialize>( + self, + deserializer: D, + ) -> Result { + self.0.deserialize(StatusDeserializer(deserializer)) + } +} +// The previous flattened decoder fed this tag through ContentDeserializer, +// which accepts numeric variant identifiers as well as strings. Preserve that +// behavior without materializing the whole response into Content first. +struct StatusDeserializer(D); +impl<'de, D: serde::Deserializer<'de>> serde::Deserializer<'de> for StatusDeserializer { + type Error = D::Error; + fn is_human_readable(&self) -> bool { + self.0.is_human_readable() + } + fn deserialize_any>(self, visitor: V) -> Result { + self.0.deserialize_any(visitor) + } + fn deserialize_identifier>( + self, + visitor: V, + ) -> Result { + self.0.deserialize_any(visitor) + } + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf + option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum ignored_any + } +} diff --git a/crates/wire/tests/response_decode.rs b/crates/wire/tests/response_decode.rs new file mode 100644 index 00000000..8f7839de --- /dev/null +++ b/crates/wire/tests/response_decode.rs @@ -0,0 +1,455 @@ +#[path = "support/response_cases.rs"] +mod cases; + +use oxidezap_wire::{ResponseEnvelope, ResponseResult}; +use serde_json::{Value, json}; + +fn compare(frame: &[u8]) { + match (cases::baseline(frame), cases::candidate(frame)) { + (Ok(base), Ok(head)) => { + assert_eq!(base, head, "{}", String::from_utf8_lossy(frame)); + assert_eq!( + serde_json::to_vec(&base).unwrap(), + serde_json::to_vec(&head).unwrap(), + "re-encoding differs" + ); + } + (Err(_), Err(_)) => {} + (base, head) => panic!( + "acceptance differs for {}: base={base:?}, head={head:?}", + String::from_utf8_lossy(frame) + ), + } +} +fn encoded(value: &Value) -> String { + serde_json::to_string(value).unwrap() +} +fn permutations(fields: &mut [(String, Value)], start: usize) { + if start == fields.len() { + let text = format!( + "{{{}}}", + fields + .iter() + .map(|(key, value)| format!( + "{}:{}", + serde_json::to_string(key).unwrap(), + encoded(value) + )) + .collect::>() + .join(",") + ); + compare(text.as_bytes()); + return; + } + for i in start..fields.len() { + fields.swap(start, i); + permutations(fields, start + 1); + fields.swap(start, i); + } +} + +#[test] +fn response_values_and_serialization_match_the_legacy_envelope() { + let fixtures = cases::fixtures(); + assert_eq!(fixtures.len(), 34); + for response in fixtures { + let bytes = serde_json::to_vec(&response).unwrap(); + let legacy = cases::LegacyEnvelope { + id: response.id, + result: response.result.clone(), + }; + assert_eq!(bytes, serde_json::to_vec(&legacy).unwrap()); + assert_eq!(cases::candidate(&bytes).unwrap(), response); + } +} + +#[test] +fn field_orders_duplicates_unknowns_types_and_depth_match_the_legacy_decoder() { + let values = [ + json!(null), + json!(false), + json!(true), + json!(0), + json!(1), + json!(2), + json!(-1), + json!(-0.0), + json!(1.0), + json!(u64::MAX), + json!(""), + json!("é\n\"\\"), + json!([]), + json!({}), + json!(["nested"]), + json!({"unknown":[null]}), + ]; + for fixture in cases::fixtures() { + let value = serde_json::to_value(&fixture).unwrap(); + let object = value.as_object().unwrap(); + let mut fields: Vec<_> = object.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + permutations(&mut fields, 0); + for key in object.keys() { + for replacement in &values { + let mut changed = value.clone(); + changed[key] = replacement.clone(); + compare(encoded(&changed).as_bytes()); + let mut duplicate = encoded(&value); + duplicate.pop(); + duplicate.push_str(&format!( + ",{}:{}}}", + serde_json::to_string(key).unwrap(), + encoded(replacement) + )); + compare(duplicate.as_bytes()); + } + let mut removed = value.clone(); + removed.as_object_mut().unwrap().remove(key); + compare(encoded(&removed).as_bytes()); + } + for extra in &values { + let mut changed = value.clone(); + changed["unknown"] = extra.clone(); + compare(encoded(&changed).as_bytes()); + } + for depth in [1, 64, 120, 126, 127, 128, 129, 130, 140] { + let mut prefix = encoded(&value); + prefix.pop(); + let frame = format!( + "{prefix},\"unknown\":{}null{}}}", + "[".repeat(depth), + "]".repeat(depth) + ); + compare(frame.as_bytes()); + } + let text = encoded(&value); + compare(&text.as_bytes()[..text.len() - 1]); + compare(format!("{text} trailing").as_bytes()); + compare(text.replace("\"status\"", "\"sta\\u0074us\"").as_bytes()); + compare(text.replace("\"id\"", "\"\\u0069d\"").as_bytes()); + compare( + text.replace("\"status\":\"ok\"", "\"status\":18446744073709551616") + .as_bytes(), + ); + } + for value in values { + compare(encoded(&value).as_bytes()); + } +} + +#[test] +fn numeric_status_tags_and_structured_error_values_remain_compatible() { + let ack: ResponseEnvelope = + serde_json::from_str(r#"{"status":0,"type":"ack","id":18446744073709551615}"#).unwrap(); + assert_eq!(ack.id, Some(u64::MAX)); + assert!(matches!(ack.result, ResponseResult::Ok { .. })); + let response: ResponseEnvelope = serde_json::from_str(r#"{"status":1,"code":"synthetic","message":"é\n\"\\","details":{"unknown":[null,1]},"id":null}"#).unwrap(); + match response.result { + ResponseResult::Error { error } => { + assert_eq!(error.code, "synthetic"); + assert_eq!(error.message, "é\n\"\\"); + assert_eq!(error.details, Some(json!({"unknown":[null,1]}))); + } + ResponseResult::Ok { .. } => panic!("an error became a success"), + } + for frame in [ + b"\xff".as_slice(), + b"{\"status\":\"ok\",\"type\":\"ack\",\"unknown\":\"\xff\"}", + b"{}", + b"{\"status\":\"unknown\"}", + ] { + assert!(cases::baseline(frame).is_err()); + assert!(cases::candidate(frame).is_err()); + } +} + +#[test] +fn structured_error_details_keep_the_reference_map_order() { + compare(br#"{"id":null,"status":"error","message":"synthetic","code":"synthetic","details":{"z":1,"a":2,"nested":{"y":1,"b":2}}}"#); + for frame in [ + br#"{"sta\uD800tus":"ok","type":"ack"}"#.as_slice(), + br#"{"status":"ok","type":"ack","id":-0}"#, + br#"{"status":"ok","type":"ack","id":1e0}"#, + ] { + compare(frame); + } +} + +// A self-describing generic key deserializer. serde_json::Value's own +// deserialize_identifier deliberately requires text, which would hide the +// non-string-key behavior this regression test needs to exercise. +#[derive(Clone)] +struct GenericKey(Value); +impl<'de> serde::Deserializer<'de> for GenericKey { + type Error = serde_json::Error; + fn deserialize_any>( + self, + visitor: V, + ) -> Result { + serde::Deserializer::deserialize_any(self.0, visitor) + } + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf + option unit unit_struct newtype_struct seq tuple tuple_struct map struct enum identifier ignored_any + } +} +impl<'de> serde::de::IntoDeserializer<'de, serde_json::Error> for GenericKey { + type Deserializer = Self; + fn into_deserializer(self) -> Self { + self + } +} +#[test] +fn generic_map_keys_match_the_previous_flattened_decoder() { + use serde::Deserialize; + use serde::de::value::{MapAccessDeserializer, MapDeserializer}; + let keys = [ + json!(0), + json!(1), + json!(2), + json!(u64::MAX), + json!(-1), + json!(1.5), + json!(7), + json!(true), + json!(null), + json!([7]), + json!({"synthetic":7}), + ]; + for key in keys { + let entries = vec![ + (GenericKey(key.clone()), json!(null)), + (GenericKey(json!("status")), json!("ok")), + (GenericKey(json!("type")), json!("ack")), + ]; + let legacy = cases::LegacyEnvelope::deserialize(MapAccessDeserializer::new( + MapDeserializer::new(entries.clone().into_iter()), + )) + .map(|v| ResponseEnvelope { + id: v.id, + result: v.result, + }); + let current = ResponseEnvelope::deserialize(MapAccessDeserializer::new( + MapDeserializer::new(entries.into_iter()), + )); + if key == json!(7) { + assert!( + legacy.is_ok(), + "the reference accepts an unknown numeric key" + ); + } + match (legacy, current) { + (Ok(base), Ok(head)) => assert_eq!(base, head), + (Err(_), Err(_)) => {} + (base, head) => panic!("generic key {key}: base={base:?}, head={head:?}"), + } + } +} + +#[test] +fn generic_byte_keys_and_numeric_status_keep_the_legacy_behavior() { + use serde::Deserialize; + use serde::de::value::{BorrowedBytesDeserializer, MapAccessDeserializer, MapDeserializer}; + for unknown in [b"unknown".as_slice(), b"\xff"] { + let entries = vec![ + ( + BorrowedBytesDeserializer::::new(unknown), + json!(null), + ), + (BorrowedBytesDeserializer::new(b"status"), json!(0)), + (BorrowedBytesDeserializer::new(b"type"), json!("ack")), + (BorrowedBytesDeserializer::new(b"id"), json!(u64::MAX)), + ]; + let legacy = cases::LegacyEnvelope::deserialize(MapAccessDeserializer::new( + MapDeserializer::new(entries.clone().into_iter()), + )) + .unwrap(); + let current = ResponseEnvelope::deserialize(MapAccessDeserializer::new( + MapDeserializer::new(entries.into_iter()), + )) + .unwrap(); + assert_eq!(legacy.id, Some(u64::MAX)); + assert_eq!(current.id, legacy.id); + assert_eq!(current.result, legacy.result); + } +} + +struct MapOnly(D); +impl<'de, D: serde::Deserializer<'de>> serde::Deserializer<'de> for MapOnly { + type Error = D::Error; + fn deserialize_any>( + self, + _visitor: V, + ) -> Result { + Err(serde::de::Error::custom( + "root deserialize_any is unavailable", + )) + } + fn deserialize_map>(self, visitor: V) -> Result { + self.0.deserialize_map(visitor) + } + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf + option unit unit_struct newtype_struct seq tuple tuple_struct struct enum identifier ignored_any + } +} +#[test] +fn root_deserializers_that_support_maps_keep_working_without_deserialize_any() { + use serde::Deserialize; + let frame = r#"{"id":1,"status":"ok","type":"ack"}"#; + let mut base = serde_json::Deserializer::from_str(frame); + let base = cases::LegacyEnvelope::deserialize(MapOnly(&mut base)).unwrap(); + let mut head = serde_json::Deserializer::from_str(frame); + let head = ResponseEnvelope::deserialize(MapOnly(&mut head)).unwrap(); + assert_eq!(base.id, head.id); + assert_eq!(base.result, head.result); +} + +#[derive(Clone)] +enum GenericValue { + Json(Value), + Bytes(&'static [u8]), +} +impl<'de> serde::Deserializer<'de> for GenericValue { + type Error = serde_json::Error; + fn deserialize_any>( + self, + visitor: V, + ) -> Result { + match self { + Self::Json(value) => serde::Deserializer::deserialize_any(value, visitor), + Self::Bytes(value) => visitor.visit_borrowed_bytes(value), + } + } + fn deserialize_u64>( + self, + visitor: V, + ) -> Result { + match self { + Self::Json(Value::String(value)) => { + visitor.visit_u64(value.parse::().map_err(serde::de::Error::custom)?) + } + other => serde::Deserializer::deserialize_any(other, visitor), + } + } + fn deserialize_option>( + self, + visitor: V, + ) -> Result { + match self { + Self::Json(Value::Null) => visitor.visit_none(), + other => visitor.visit_some(other), + } + } + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u128 f32 f64 char str string bytes byte_buf + unit unit_struct newtype_struct seq tuple tuple_struct map struct enum identifier ignored_any + } +} +impl<'de> serde::de::IntoDeserializer<'de, serde_json::Error> for GenericValue { + type Deserializer = Self; + fn into_deserializer(self) -> Self { + self + } +} +#[test] +fn generic_byte_tag_values_match_the_reference_acceptance() { + use serde::Deserialize; + use serde::de::value::{BorrowedBytesDeserializer, MapAccessDeserializer, MapDeserializer}; + for status in [ + GenericValue::Json(json!("ok")), + GenericValue::Bytes(b"ok"), + GenericValue::Json(json!(0)), + ] { + for kind in [ + GenericValue::Json(json!("ack")), + GenericValue::Bytes(b"ack"), + GenericValue::Json(json!(0)), + ] { + let entries = vec![ + ( + BorrowedBytesDeserializer::::new(b"status"), + status.clone(), + ), + (BorrowedBytesDeserializer::new(b"type"), kind), + ( + BorrowedBytesDeserializer::new(b"unknown"), + GenericValue::Bytes(b"\xff"), + ), + ]; + let base = cases::LegacyEnvelope::deserialize(MapAccessDeserializer::new( + MapDeserializer::new(entries.clone().into_iter()), + )) + .map(|v| ResponseEnvelope { + id: v.id, + result: v.result, + }); + let head = ResponseEnvelope::deserialize(MapAccessDeserializer::new( + MapDeserializer::new(entries.into_iter()), + )); + match (base, head) { + (Ok(base), Ok(head)) => assert_eq!(base, head), + (Err(_), Err(_)) => {} + (base, head) => panic!("byte tag acceptance changed: base={base:?}, head={head:?}"), + } + } + } +} + +#[test] +fn generic_key_identifier_restrictions_are_not_bypassed() { + use serde::Deserialize; + use serde::de::value::{MapAccessDeserializer, MapDeserializer}; + // Value's identifier method rejects a numeric key even though its any + // method accepts it. Keep the old entry point, not just the resulting type. + let entries = vec![ + (json!(7), json!(null)), + (json!("status"), json!("ok")), + (json!("type"), json!("ack")), + ]; + let base = cases::LegacyEnvelope::deserialize(MapAccessDeserializer::new( + MapDeserializer::new(entries.clone().into_iter()), + )); + let head = ResponseEnvelope::deserialize(MapAccessDeserializer::new(MapDeserializer::new( + entries.into_iter(), + ))); + assert!(base.is_err()); + assert!(head.is_err()); +} +#[test] +fn generic_id_option_and_integer_hints_match_the_reference() { + use serde::Deserialize; + use serde::de::value::{MapAccessDeserializer, MapDeserializer}; + for key in [json!("id"), json!(0)] { + for id in [ + json!("17"), + json!("18446744073709551615"), + json!(null), + json!("invalid"), + json!("18446744073709551616"), + ] { + let entries = vec![ + (GenericKey(key.clone()), GenericValue::Json(id.clone())), + (GenericKey(json!("status")), GenericValue::Json(json!("ok"))), + (GenericKey(json!("type")), GenericValue::Json(json!("ack"))), + ]; + let base = cases::LegacyEnvelope::deserialize(MapAccessDeserializer::new( + MapDeserializer::new(entries.clone().into_iter()), + )) + .map(|v| ResponseEnvelope { + id: v.id, + result: v.result, + }); + let head = ResponseEnvelope::deserialize(MapAccessDeserializer::new( + MapDeserializer::new(entries.into_iter()), + )); + if key == json!("id") && id == json!("17") { + assert_eq!(base.as_ref().unwrap().id, Some(17)); + } + match (base, head) { + (Ok(base), Ok(head)) => assert_eq!(base, head), + (Err(base), Err(head)) => assert_eq!(base.to_string(), head.to_string()), + (base, head) => panic!("id hint changed for {id}: base={base:?}, head={head:?}"), + } + } + } +} diff --git a/crates/wire/tests/response_decode_cost.rs b/crates/wire/tests/response_decode_cost.rs new file mode 100644 index 00000000..1f7d4cf7 --- /dev/null +++ b/crates/wire/tests/response_decode_cost.rs @@ -0,0 +1,91 @@ +//! Synthetic decode-and-drop timing; excludes socket, startup, stdout and daemon. +#[path = "support/response_cases.rs"] +mod cases; +use std::hint::black_box; + +// Native measurement only; the wire crate has no wacore dependency. +#[allow(clippy::disallowed_methods)] +fn measure( + frame: &[u8], + decode: fn(&[u8]) -> serde_json::Result, + rounds: usize, +) -> u128 { + let start = std::time::Instant::now(); + for _ in 0..rounds { + drop(black_box(decode(black_box(frame)))); + } + start.elapsed().as_nanos() / rounds as u128 +} +#[allow(clippy::print_stdout)] +fn paired(label: &str, frame: &[u8], rounds: usize) { + assert_eq!(cases::baseline(frame).ok(), cases::candidate(frame).ok()); + drop(cases::baseline(frame)); + drop(cases::candidate(frame)); + for pair in 0..12 { + let (base, head) = if pair % 2 == 0 { + ( + measure(frame, cases::baseline, rounds), + measure(frame, cases::candidate, rounds), + ) + } else { + let head = measure(frame, cases::candidate, rounds); + (measure(frame, cases::baseline, rounds), head) + }; + println!( + "{label} pair={pair} rounds={rounds} bytes={} base_ns={base} head_ns={head}", + frame.len() + ); + } +} +#[test] +#[ignore = "release-only paired timing comparison"] +fn response_decode_timing() { + let rounds = std::env::var("OXIDEZAP_BENCH_ROUNDS") + .ok() + .and_then(|v| v.parse().ok()) + .filter(|v| *v > 0) + .unwrap_or(200); + for count in [1, 100, 1000] { + for escaped in [false, true] { + paired( + &format!("chats={count} escaped={escaped}"), + &cases::chats(count, escaped), + rounds, + ); + } + } + for (i, response) in cases::fixtures().iter().enumerate() { + paired( + &format!("small={i}"), + &serde_json::to_vec(response).unwrap(), + rounds * 5, + ); + } + for (i, frame) in [ + r#"{}"#, + r#"{"id":1,"status":"wrong"}"#, + r#"{"status":0,"type":"ack"}"#, + r#"{"status":1,"code":"synthetic","message":"synthetic"}"#, + ] + .iter() + .enumerate() + { + paired(&format!("edge={i}"), frame.as_bytes(), rounds * 5); + } +} +#[test] +#[ignore = "isolated Callgrind baseline entry point"] +fn response_decode_instructions_baseline() { + let frame = cases::chats(100, true); + for _ in 0..100 { + drop(black_box(cases::baseline(black_box(&frame)).unwrap())); + } +} +#[test] +#[ignore = "isolated Callgrind production entry point"] +fn response_decode_instructions_candidate() { + let frame = cases::chats(100, true); + for _ in 0..100 { + drop(black_box(cases::candidate(black_box(&frame)).unwrap())); + } +} diff --git a/crates/wire/tests/response_decode_heap.rs b/crates/wire/tests/response_decode_heap.rs new file mode 100644 index 00000000..c71bc996 --- /dev/null +++ b/crates/wire/tests/response_decode_heap.rs @@ -0,0 +1,94 @@ +use std::alloc::{GlobalAlloc, Layout, System}; +use std::cell::Cell; +thread_local! { + static ACTIVE: Cell = const { Cell::new(false) }; + static LIVE: Cell = const { Cell::new(0) }; + static PEAK: Cell = const { Cell::new(0) }; + static CALLS: Cell = const { Cell::new(0) }; +} +struct Counting; +fn add(size: usize) { + if ACTIVE.try_with(Cell::get).unwrap_or(false) { + LIVE.with(|v| { + v.set(v.get() + size); + PEAK.with(|p| p.set(p.get().max(v.get()))); + }); + CALLS.with(|v| v.set(v.get() + 1)); + } +} +fn subtract(size: usize) { + if ACTIVE.try_with(Cell::get).unwrap_or(false) { + LIVE.with(|v| v.set(v.get() - size)); + } +} +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + // SAFETY: allocation layout forwarded unchanged. + let ptr = unsafe { System.alloc(layout) }; + if !ptr.is_null() { + add(layout.size()); + } + ptr + } + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + // SAFETY: allocation layout forwarded unchanged. + let ptr = unsafe { System.alloc_zeroed(layout) }; + if !ptr.is_null() { + add(layout.size()); + } + ptr + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + subtract(layout.size()); + // SAFETY: original allocation and layout forwarded unchanged. + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 { + // SAFETY: original allocation and requested size forwarded unchanged. + let next = unsafe { System.realloc(ptr, layout, size) }; + if !next.is_null() { + subtract(layout.size()); + add(size); + } + next + } +} +#[global_allocator] +static ALLOCATOR: Counting = Counting; +fn sample(f: impl FnOnce() -> oxidezap_wire::ResponseEnvelope) -> (usize, usize, usize) { + LIVE.with(|v| v.set(0)); + PEAK.with(|v| v.set(0)); + CALLS.with(|v| v.set(0)); + ACTIVE.with(|v| v.set(true)); + let result = std::hint::black_box(f()); + let metrics = ( + PEAK.with(Cell::get), + LIVE.with(Cell::get), + CALLS.with(Cell::get), + ); + drop(result); + assert_eq!(LIVE.with(Cell::get), 0); + ACTIVE.with(|v| v.set(false)); + metrics +} + +#[path = "support/response_cases.rs"] +mod cases; +#[test] +#[ignore = "release-only requested heap comparison, separate from timing"] +fn response_decode_heap() { + for count in [1, 100, 1000] { + for escaped in [false, true] { + let frame = cases::chats(count, escaped); + drop(cases::baseline(&frame).unwrap()); + drop(cases::candidate(&frame).unwrap()); + let base = sample(|| cases::baseline(&frame).unwrap()); + let head = sample(|| cases::candidate(&frame).unwrap()); + assert_eq!(base.1, head.1, "retained DTO heap must be unchanged"); + println!( + "chats={count} escaped={escaped} base_peak={} head_peak={} base_live={} head_live={} base_calls={} head_calls={}", + base.0, head.0, base.1, head.1, base.2, head.2 + ); + } + } +} diff --git a/crates/wire/tests/support/response_cases.rs b/crates/wire/tests/support/response_cases.rs new file mode 100644 index 00000000..edc7b8e3 --- /dev/null +++ b/crates/wire/tests/support/response_cases.rs @@ -0,0 +1,141 @@ +#![allow(dead_code)] +use oxidezap_wire::{ApiError, DaemonResponse, ResponseEnvelope, ResponseResult}; +use serde::{Deserialize, Serialize}; + +/// The previous derived decoder, kept as an independent compatibility reference. +#[derive(Debug, Serialize, Deserialize)] +pub struct LegacyEnvelope { + pub id: Option, + #[serde(flatten)] + pub result: ResponseResult, +} +#[inline(never)] +pub fn baseline(frame: &[u8]) -> serde_json::Result { + serde_json::from_slice::(frame).map(|v| ResponseEnvelope { + id: v.id, + result: v.result, + }) +} +#[inline(never)] +pub fn candidate(frame: &[u8]) -> serde_json::Result { + serde_json::from_slice(frame) +} +pub fn fixtures() -> Vec { + use DaemonResponse::*; + let responses = vec![ + Ack, + AccountSelected { local_account: 1 }, + Status(Default::default()), + Messages { + messages: vec![Default::default()], + next_cursor: None, + }, + Message(Default::default()), + MessageSent { + id: "synthetic".into(), + timestamp_ms: 0, + enqueued: false, + }, + Chats { + chats: vec![Default::default()], + next_cursor: None, + }, + Chat(Default::default()), + MediaDownloaded { + message_id: "synthetic".into(), + local_path: "/synthetic/file".into(), + size_bytes: 1, + }, + MediaRetryRequested { + message_id: "synthetic".into(), + }, + Contacts { + contacts: vec![Default::default()], + }, + Contact(Default::default()), + ContactCheck { + phone: "synthetic".into(), + is_registered: false, + jid: None, + }, + Groups { + groups: vec![Default::default()], + }, + Group(Default::default()), + GroupInviteLink { + link: "https://example.invalid".into(), + }, + GroupCreated { + jid: "synthetic@example.invalid".into(), + }, + GroupJoined { + jid: "synthetic@example.invalid".into(), + pending_approval: false, + }, + GroupJoinRequests { requests: vec![] }, + Polls { + polls: vec![Default::default()], + }, + Poll(Default::default()), + Channels { + channels: vec![Default::default()], + }, + Channel(Default::default()), + MediaBackfilled { + requested: 1, + downloaded: 1, + }, + ChatsCleaned { removed: 1 }, + MessagesPurged { purged: 1 }, + Accounts { accounts: vec![] }, + PairCode { + code: "synthetic".into(), + expires_at_ms: 0, + }, + Profile(Default::default()), + Calls { calls: vec![] }, + HistoryCoverage { + chat_jid: "synthetic@example.invalid".into(), + oldest_ts: None, + newest_ts: None, + stored_count: 0, + }, + Storage(Default::default()), + Doctor(Default::default()), + ]; + let mut fixtures: Vec<_> = responses + .into_iter() + .map(|v| ResponseEnvelope::success(1, v)) + .collect(); + fixtures.push(ResponseEnvelope::error( + None, + ApiError::invalid_argument("synthetic é\n\"\\") + .with_details(serde_json::json!({"synthetic": [null, 1, "é"]})), + )); + fixtures +} + +pub fn chats(count: usize, escaped: bool) -> Vec { + let chats = (0..count) + .map(|i| oxidezap_wire::ChatDto { + jid: format!("synthetic-{i}@example.invalid"), + name: format!("Synthetic conversation {i}"), + last_message_preview: Some(if escaped { + "Synthetic é\nwith \"quotes\" and \\ slash".repeat(3) + } else { + "Synthetic conversation preview with ordinary text".repeat(3) + }), + unread_count: (i % 12) as u32, + last_message_ts: Some(123456789), + ..Default::default() + }) + .collect(); + serde_json::to_vec(&ResponseEnvelope::success( + 1, + DaemonResponse::Chats { + chats, + next_cursor: None, + }, + )) + .unwrap() +}