From 6ebe95f0eb0017ce01b7aa00571be28ef21b0ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Fri, 30 Oct 2026 00:28:34 +0000 Subject: [PATCH 1/4] perf(wire): remove an intermediate response decoding copy --- crates/wire/src/envelope.rs | 4 +- crates/wire/src/envelope/response_decode.rs | 143 ++++++++++++++++ crates/wire/tests/response_decode.rs | 175 ++++++++++++++++++++ crates/wire/tests/response_decode_cost.rs | 91 ++++++++++ crates/wire/tests/response_decode_heap.rs | 94 +++++++++++ crates/wire/tests/support/response_cases.rs | 141 ++++++++++++++++ 6 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 crates/wire/src/envelope/response_decode.rs create mode 100644 crates/wire/tests/response_decode.rs create mode 100644 crates/wire/tests/response_decode_cost.rs create mode 100644 crates/wire/tests/response_decode_heap.rs create mode 100644 crates/wire/tests/support/response_cases.rs 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..1a12e234 --- /dev/null +++ b/crates/wire/src/envelope/response_decode.rs @@ -0,0 +1,143 @@ +//! 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 { + Ok( + match Decoded::deserialize(ResponseDeserializer(deserializer))? { + Decoded::Ok { id, payload } => Self { + id, + result: ResponseResult::Ok { payload }, + }, + Decoded::Error { id, error } => Self { + 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, + }, +} +struct ResponseDeserializer(D); +impl<'de, D: serde::Deserializer<'de>> serde::Deserializer<'de> for ResponseDeserializer { + 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(ResponseVisitor(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 + } +} +struct ResponseVisitor(V); +impl<'de, V: serde::de::Visitor<'de>> serde::de::Visitor<'de> for ResponseVisitor { + type Value = V::Value; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + self.0.expecting(f) + } + fn visit_map>(self, inner: A) -> Result { + self.0.visit_map(ResponseFields { + inner, + status: false, + }) + } +} +// Borrow ordinary keys; escaped keys may require owned storage. +#[derive(Deserialize)] +struct FieldName<'a>(#[serde(borrow)] std::borrow::Cow<'a, str>); +struct ResponseFields { + inner: A, + status: bool, +} +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> { + match self.inner.next_key::>()? { + None => Ok(None), + Some(key) => { + self.status = key.0 == "status"; + match key.0 { + std::borrow::Cow::Borrowed(key) => seed.deserialize( + serde::de::value::BorrowedStrDeserializer::::new(key), + ), + std::borrow::Cow::Owned(key) => { + seed.deserialize(serde::de::value::StringDeserializer::::new(key)) + } + } + .map(Some) + } + } + } + fn next_value_seed>( + &mut self, + seed: V, + ) -> Result { + if self.status { + self.inner.next_value_seed(StatusSeed(seed)) + } else { + self.inner.next_value_seed(seed) + } + } + fn size_hint(&self) -> Option { + self.inner.size_hint() + } +} +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..af459071 --- /dev/null +++ b/crates/wire/tests/response_decode.rs @@ -0,0 +1,175 @@ +#[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); + } +} 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() +} From c3dbccd95a8eb01f2bf7f73709d7b4d85a2b9586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Fri, 30 Oct 2026 00:43:09 +0000 Subject: [PATCH 2/4] fix(wire): forward generic response key types without string coercion --- crates/wire/src/envelope/response_decode.rs | 122 +++++++++++++++++--- crates/wire/tests/response_decode.rs | 92 +++++++++++++++ 2 files changed, 196 insertions(+), 18 deletions(-) diff --git a/crates/wire/src/envelope/response_decode.rs b/crates/wire/src/envelope/response_decode.rs index 1a12e234..76b5dc2c 100644 --- a/crates/wire/src/envelope/response_decode.rs +++ b/crates/wire/src/envelope/response_decode.rs @@ -65,9 +65,6 @@ impl<'de, V: serde::de::Visitor<'de>> serde::de::Visitor<'de> for ResponseVisito }) } } -// Borrow ordinary keys; escaped keys may require owned storage. -#[derive(Deserialize)] -struct FieldName<'a>(#[serde(borrow)] std::borrow::Cow<'a, str>); struct ResponseFields { inner: A, status: bool, @@ -78,21 +75,11 @@ impl<'de, A: serde::de::MapAccess<'de>> serde::de::MapAccess<'de> for ResponseFi &mut self, seed: K, ) -> Result, A::Error> { - match self.inner.next_key::>()? { - None => Ok(None), - Some(key) => { - self.status = key.0 == "status"; - match key.0 { - std::borrow::Cow::Borrowed(key) => seed.deserialize( - serde::de::value::BorrowedStrDeserializer::::new(key), - ), - std::borrow::Cow::Owned(key) => { - seed.deserialize(serde::de::value::StringDeserializer::::new(key)) - } - } - .map(Some) - } - } + self.status = false; + self.inner.next_key_seed(KeySeed { + seed, + status: &mut self.status, + }) } fn next_value_seed>( &mut self, @@ -108,6 +95,105 @@ impl<'de, A: serde::de::MapAccess<'de>> serde::de::MapAccess<'de> for ResponseFi 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, + status: &'a mut bool, +} +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, + status: self.status, + }) + } +} +struct KeyDeserializer<'a, D> { + inner: D, + status: &'a mut bool, +} +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_any(KeyVisitor { + inner, + status: self.status, + }) + } + 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, + status: &'a mut bool, +} +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.status = value == "status"; + self.inner.visit_str(value) + } + fn visit_borrowed_str(self, value: &'de str) -> Result { + *self.status = value == "status"; + self.inner.visit_borrowed_str(value) + } + fn visit_string(self, value: String) -> Result { + *self.status = value == "status"; + self.inner.visit_string(value) + } + fn visit_bytes(self, value: &[u8]) -> Result { + *self.status = value == b"status"; + self.inner.visit_bytes(value) + } + fn visit_borrowed_bytes(self, value: &'de [u8]) -> Result { + *self.status = value == b"status"; + self.inner.visit_borrowed_bytes(value) + } + fn visit_byte_buf(self, value: Vec) -> Result { + *self.status = value == b"status"; + 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) + } +} struct StatusSeed(S); impl<'de, S: serde::de::DeserializeSeed<'de>> serde::de::DeserializeSeed<'de> for StatusSeed { type Value = S::Value; diff --git a/crates/wire/tests/response_decode.rs b/crates/wire/tests/response_decode.rs index af459071..7869b328 100644 --- a/crates/wire/tests/response_decode.rs +++ b/crates/wire/tests/response_decode.rs @@ -173,3 +173,95 @@ fn structured_error_details_keep_the_reference_map_order() { 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!(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!(current.id, legacy.id); + assert_eq!(current.result, legacy.result); + } +} From bdccb79cf5ffe577958be29f2a5fcc35ad8ac965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Fri, 30 Oct 2026 00:53:20 +0000 Subject: [PATCH 3/4] fix(wire): retain map-only deserializer entry points --- crates/wire/src/envelope/response_decode.rs | 2 +- crates/wire/tests/response_decode.rs | 117 ++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/crates/wire/src/envelope/response_decode.rs b/crates/wire/src/envelope/response_decode.rs index 76b5dc2c..16411f43 100644 --- a/crates/wire/src/envelope/response_decode.rs +++ b/crates/wire/src/envelope/response_decode.rs @@ -45,7 +45,7 @@ impl<'de, D: serde::Deserializer<'de>> serde::Deserializer<'de> for ResponseDese self.0.is_human_readable() } fn deserialize_any>(self, visitor: V) -> Result { - self.0.deserialize_any(ResponseVisitor(visitor)) + self.0.deserialize_map(ResponseVisitor(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 diff --git a/crates/wire/tests/response_decode.rs b/crates/wire/tests/response_decode.rs index 7869b328..5e8fb361 100644 --- a/crates/wire/tests/response_decode.rs +++ b/crates/wire/tests/response_decode.rs @@ -203,6 +203,12 @@ 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), @@ -265,3 +271,114 @@ fn generic_byte_keys_and_numeric_status_keep_the_legacy_behavior() { 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_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 u64 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:?}"), + } + } + } +} From c6ac0ee55127fb1221e2372c072aafcc838dcfa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Fri, 30 Oct 2026 01:24:34 +0000 Subject: [PATCH 4/4] fix(wire): preserve typed identifier and request id decoding hints --- crates/wire/src/envelope/response_decode.rs | 135 ++++++++++++-------- crates/wire/tests/response_decode.rs | 73 ++++++++++- 2 files changed, 153 insertions(+), 55 deletions(-) diff --git a/crates/wire/src/envelope/response_decode.rs b/crates/wire/src/envelope/response_decode.rs index 16411f43..ab1b31b1 100644 --- a/crates/wire/src/envelope/response_decode.rs +++ b/crates/wire/src/envelope/response_decode.rs @@ -7,18 +7,33 @@ use crate::{ApiError, DaemonResponse}; impl<'de> Deserialize<'de> for ResponseEnvelope { fn deserialize>(deserializer: D) -> Result { - Ok( - match Decoded::deserialize(ResponseDeserializer(deserializer))? { - Decoded::Ok { id, payload } => Self { - id, - result: ResponseResult::Ok { payload }, - }, - Decoded::Error { id, error } => Self { - id, - result: ResponseResult::Error { error }, - }, + // 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 }, + }, + }) } } @@ -38,36 +53,31 @@ enum Decoded { error: ApiError, }, } -struct ResponseDeserializer(D); -impl<'de, D: serde::Deserializer<'de>> serde::Deserializer<'de> for ResponseDeserializer { - 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_map(ResponseVisitor(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 - } +#[derive(Clone, Copy)] +enum FieldKind { + Other, + Status, + Id, } -struct ResponseVisitor(V); -impl<'de, V: serde::de::Visitor<'de>> serde::de::Visitor<'de> for ResponseVisitor { - type Value = V::Value; - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - self.0.expecting(f) +impl FieldKind { + fn text(value: &str) -> Self { + match value { + "status" => Self::Status, + "id" => Self::Id, + _ => Self::Other, + } } - fn visit_map>(self, inner: A) -> Result { - self.0.visit_map(ResponseFields { - inner, - status: false, - }) + fn bytes(value: &[u8]) -> Self { + match value { + b"status" => Self::Status, + b"id" => Self::Id, + _ => Self::Other, + } } } struct ResponseFields { inner: A, - status: bool, + field: FieldKind, } impl<'de, A: serde::de::MapAccess<'de>> serde::de::MapAccess<'de> for ResponseFields { type Error = A::Error; @@ -75,20 +85,20 @@ impl<'de, A: serde::de::MapAccess<'de>> serde::de::MapAccess<'de> for ResponseFi &mut self, seed: K, ) -> Result, A::Error> { - self.status = false; + self.field = FieldKind::Other; self.inner.next_key_seed(KeySeed { seed, - status: &mut self.status, + field: &mut self.field, }) } fn next_value_seed>( &mut self, seed: V, ) -> Result { - if self.status { - self.inner.next_value_seed(StatusSeed(seed)) - } else { - self.inner.next_value_seed(seed) + 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 { @@ -100,20 +110,20 @@ impl<'de, A: serde::de::MapAccess<'de>> serde::de::MapAccess<'de> for ResponseFi // flattened decoder allowed downstream visitors to ignore those keys. struct KeySeed<'a, S> { seed: S, - status: &'a mut bool, + 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, - status: self.status, + field: self.field, }) } } struct KeyDeserializer<'a, D> { inner: D, - status: &'a mut bool, + field: &'a mut FieldKind, } impl<'de, D: Deserializer<'de>> Deserializer<'de> for KeyDeserializer<'_, D> { type Error = D::Error; @@ -121,9 +131,9 @@ impl<'de, D: Deserializer<'de>> Deserializer<'de> for KeyDeserializer<'_, D> { self.inner.is_human_readable() } fn deserialize_any>(self, inner: V) -> Result { - self.inner.deserialize_any(KeyVisitor { + self.inner.deserialize_identifier(KeyVisitor { inner, - status: self.status, + field: self.field, }) } serde::forward_to_deserialize_any! { @@ -133,7 +143,7 @@ impl<'de, D: Deserializer<'de>> Deserializer<'de> for KeyDeserializer<'_, D> { } struct KeyVisitor<'a, V> { inner: V, - status: &'a mut bool, + field: &'a mut FieldKind, } macro_rules! forward_key_values { ($($method:ident($ty:ty)),* $(,)?) => {$( @@ -147,29 +157,29 @@ impl<'de, V: serde::de::Visitor<'de>> serde::de::Visitor<'de> for KeyVisitor<'_, 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) } + 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.status = value == "status"; + *self.field = FieldKind::text(value); self.inner.visit_str(value) } fn visit_borrowed_str(self, value: &'de str) -> Result { - *self.status = value == "status"; + *self.field = FieldKind::text(value); self.inner.visit_borrowed_str(value) } fn visit_string(self, value: String) -> Result { - *self.status = value == "status"; + *self.field = FieldKind::text(&value); self.inner.visit_string(value) } fn visit_bytes(self, value: &[u8]) -> Result { - *self.status = value == b"status"; + *self.field = FieldKind::bytes(value); self.inner.visit_bytes(value) } fn visit_borrowed_bytes(self, value: &'de [u8]) -> Result { - *self.status = value == b"status"; + *self.field = FieldKind::bytes(value); self.inner.visit_borrowed_bytes(value) } fn visit_byte_buf(self, value: Vec) -> Result { - *self.status = value == b"status"; + *self.field = FieldKind::bytes(&value); self.inner.visit_byte_buf(value) } fn visit_none(self) -> Result { @@ -194,6 +204,23 @@ impl<'de, V: serde::de::Visitor<'de>> serde::de::Visitor<'de> for KeyVisitor<'_, 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; diff --git a/crates/wire/tests/response_decode.rs b/crates/wire/tests/response_decode.rs index 5e8fb361..8f7839de 100644 --- a/crates/wire/tests/response_decode.rs +++ b/crates/wire/tests/response_decode.rs @@ -267,6 +267,7 @@ fn generic_byte_keys_and_numeric_status_keep_the_legacy_behavior() { 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); } @@ -319,6 +320,17 @@ impl<'de> serde::Deserializer<'de> for GenericValue { 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, @@ -329,7 +341,7 @@ impl<'de> serde::Deserializer<'de> for GenericValue { } } serde::forward_to_deserialize_any! { - bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf + 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 } } @@ -382,3 +394,62 @@ fn generic_byte_tag_values_match_the_reference_acceptance() { } } } + +#[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:?}"), + } + } + } +}