Skip to main content

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}