rs_teststand_bridge/event.rs
1//! The wire type: one engine message, flattened into data.
2//!
3//! Separate from the engine thread in [`crate::host`] and from every transport,
4//! because it is what they all agree on. A transport decides how bytes move; this
5//! decides what a message *is* once no COM reference is left in it.
6
7use rs_teststand::{UIMessage, UIMessageCode};
8use rs_teststand_serde::PropertyObjectValue as _;
9
10use crate::Error;
11
12/// One message, flattened into something that can cross a thread or a wire.
13///
14/// The COM objects a [`UIMessage`] refers to are bound to the
15/// engine's apartment and cannot leave it, so what travels is the data a
16/// consumer actually needs.
17#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
18pub struct MessageEvent {
19 /// The raw message code.
20 pub code: i32,
21 /// The numeric payload.
22 ///
23 /// Serialized so a non-finite value never reaches the
24 /// wire as `null`: JSON has no NaN or infinity, and a strictly typed reader
25 /// that expects a number cannot accept one.
26 #[serde(serialize_with = "finite")]
27 pub numeric: f64,
28 /// The string payload.
29 pub text: String,
30 /// The object payload, as JSON.
31 ///
32 /// A message's third slot holds an `ActiveX` reference, which is how a
33 /// sequence hands over a whole container instead of packing fields into
34 /// [`text`](Self::text). That reference is bound to the engine's process and
35 /// cannot be sent anywhere, so what survives here is the tree walked into
36 /// data. `None` means the slot was empty or the policy declined it.
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub payload: Option<String>,
39 /// Whether the posting thread was blocked awaiting acknowledgement.
40 ///
41 /// Informational by the time a subscriber sees it: the host has already
42 /// acknowledged the message, because holding it would stall the sequence.
43 pub synchronous: bool,
44 /// The execution that posted it, when there was one.
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub execution_id: Option<i32>,
47}
48
49impl MessageEvent {
50 /// Whether a sequence posted this, rather than the engine.
51 #[must_use]
52 pub const fn is_from_sequence(&self) -> bool {
53 UIMessageCode::is_user_message(self.code)
54 }
55
56 /// The engine's name for the code, when it has one.
57 #[must_use]
58 pub fn engine_code(&self) -> Option<UIMessageCode> {
59 UIMessageCode::from_bits(self.code).ok()
60 }
61
62 /// Flattens a live message into something that can leave this process.
63 ///
64 /// This is the boundary. Everything a [`UIMessage`] refers to is bound to
65 /// the engine's apartment; everything on this side of the call is data, and
66 /// can go to another thread, another process, or a socket.
67 ///
68 /// All three payload slots are carried, including the object one, which is
69 /// the slot that makes the difference: a sequence can put a container in it
70 /// and a receiver gets the whole structure without either side agreeing on
71 /// a text format. `policy` decides when that is worth the cost.
72 ///
73 /// # Errors
74 /// [`Error`] if a COM call fails or the object cannot be walked.
75 pub fn from_ui_message(message: &UIMessage, policy: PayloadPolicy) -> Result<Self, Error> {
76 let code = message.event()?;
77 let payload = match message.activex_data()? {
78 Some(container) if policy.admits(code) => match container.to_value() {
79 Ok(value) => Some(serde_json::to_string(&value)?),
80 // A sequence context contains itself, so walking the whole thing
81 // is refused. That is not a reason to lose the message: the code
82 // and text still matter, and a host that wants the data asks for
83 // a named subtree. See `PayloadPolicy`.
84 Err(rs_teststand::Error::RecursionLimit { .. }) => None,
85 Err(error) => return Err(error.into()),
86 },
87 Some(_) | None => None,
88 };
89 Ok(Self {
90 code,
91 numeric: message.numeric_data()?,
92 text: message.string_data()?,
93 payload,
94 synchronous: message.is_synchronous()?,
95 execution_id: message
96 .execution()?
97 .map(|execution| execution.id())
98 .transpose()?,
99 })
100 }
101}
102
103/// Writes a float that is always a JSON number.
104///
105/// `serde_json` renders a non-finite `f64` as `null`, which is valid JSON and
106/// useless to a reader whose schema says "number". Progress percentages and
107/// counts are what this field actually carries, so a non-finite value is a
108/// defect upstream rather than data worth preserving: it becomes zero, and the
109/// wire format keeps its promise that `numeric` is always a number.
110#[allow(
111 clippy::trivially_copy_pass_by_ref,
112 reason = "serde requires this exact signature for serialize_with"
113)]
114fn finite<S: serde::Serializer>(value: &f64, serializer: S) -> Result<S::Ok, S::Error> {
115 serializer.serialize_f64(if value.is_finite() { *value } else { 0.0 })
116}
117
118/// Which messages get their object payload serialized.
119///
120/// Walking a property tree is not free, and the engine puts objects in the slot
121/// for its own messages as well as a sequence's, so this is a real choice rather
122/// than a switch nobody needs.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
124pub enum PayloadPolicy {
125 /// Serialize only what a sequence posted itself.
126 ///
127 /// The default, and the one to keep unless you know otherwise. The engine
128 /// fills the slot for `UIMsg_StartFileExecution` and `UIMsg_EndFileExecution`
129 /// with the **sequence file**, so serializing those yields the entire file,
130 /// every step and every property, on every run. A host that wants the file
131 /// should ask for it by name rather than have it pushed.
132 #[default]
133 SequenceMessagesOnly,
134 /// Serialize whatever is in the slot, including the engine's own objects.
135 ///
136 /// Useful for diagnosis. Expect large documents.
137 Everything,
138 /// Never serialize. Codes, numbers and text only.
139 Never,
140}
141
142impl PayloadPolicy {
143 /// Whether a message with this code should have its object serialized.
144 #[must_use]
145 pub const fn admits(self, code: i32) -> bool {
146 match self {
147 Self::Everything => true,
148 Self::Never => false,
149 Self::SequenceMessagesOnly => UIMessageCode::is_user_message(code),
150 }
151 }
152}