Skip to main content

thread_conformance/
relay.rs

1//! Relay conformance — does a presence relay speak [presence-wire-v0.1]?
2//!
3//! [presence-wire-v0.1]: https://github.com/Pixygon/thread-spec/blob/main/specs/presence-wire-v0.1.md
4//!
5//! The substance is the **pure validators** — [`check_welcome`] and [`check_pose`]
6//! turn a wire message into conformance clauses with no network, so the spec's
7//! rules are pinned by unit tests today. [`probe`] is the thin live driver: it
8//! connects to a relay, joins, and runs those validators on the real messages it
9//! sees — ready the moment a relay is deployed.
10
11use serde_json::Value;
12
13use crate::{Clause, Severity};
14
15/// Extract an array of exactly `n` finite floats at `key`, if present and well-formed.
16fn floats(v: &Value, key: &str, n: usize) -> Option<Vec<f64>> {
17    let arr = v.get(key)?.as_array()?;
18    if arr.len() != n {
19        return None;
20    }
21    let out: Vec<f64> = arr
22        .iter()
23        .filter_map(Value::as_f64)
24        .filter(|f| f.is_finite())
25        .collect();
26    (out.len() == n).then_some(out)
27}
28
29/// Validate the `welcome` a relay sends in reply to `observe` (§2). An observer
30/// watches without entering, so its welcome differs from a joiner's in exactly
31/// two ways — it says so, and it is given the non-occupant id 0 — while still
32/// carrying the roster **as it stands**, so a watcher arriving mid-session sees
33/// who is already there rather than only who arrives next.
34pub fn check_observer_welcome(msg: &Value) -> Vec<Clause> {
35    let mut clauses = check_welcome(msg);
36    clauses.push(Clause {
37        name: "observe welcome is marked observer",
38        severity: Severity::Error,
39        pass: msg.get("observer").and_then(Value::as_bool) == Some(true),
40        notes: match msg.get("observer") {
41            Some(v) => vec![format!("observer = {v}")],
42            None => vec!["no `observer` field — a watcher cannot tell it is one".into()],
43        },
44    });
45    clauses.push(Clause {
46        name: "observer is not an occupant (id 0)",
47        severity: Severity::Error,
48        pass: msg.get("id").and_then(Value::as_u64) == Some(0),
49        notes: vec![],
50    });
51    clauses
52}
53
54/// Validate a `welcome` message (relay→client, in reply to `join`). Per §2 a
55/// conformant welcome assigns an occupant `id`, lists `occupants`, and declares a
56/// `tick_hz`.
57pub fn check_welcome(msg: &Value) -> Vec<Clause> {
58    let is_welcome = msg.get("t").and_then(Value::as_str) == Some("welcome");
59    vec![
60        Clause {
61            name: "welcome tagged t=welcome",
62            severity: Severity::Error,
63            pass: is_welcome,
64            notes: if is_welcome {
65                vec![]
66            } else {
67                vec![format!("t = {:?}", msg.get("t"))]
68            },
69        },
70        Clause {
71            name: "welcome assigns an occupant id",
72            severity: Severity::Error,
73            pass: msg.get("id").and_then(Value::as_u64).is_some(),
74            notes: vec![],
75        },
76        Clause {
77            name: "welcome lists occupants",
78            severity: Severity::Error,
79            pass: msg.get("occupants").map(Value::is_array).unwrap_or(false),
80            notes: vec![],
81        },
82        Clause {
83            name: "welcome declares tick_hz",
84            severity: Severity::Warn,
85            pass: msg
86                .get("tick_hz")
87                .and_then(Value::as_u64)
88                .is_some_and(|h| h > 0),
89            notes: vec![],
90        },
91    ]
92}
93
94/// Validate a `pose` message (the hot path, §3–§4). `ts` (server stamp) and `v`
95/// (velocity) are **mandatory** — they're exactly what a client needs to
96/// interpolate without trusting peer clocks — so their absence is an Error.
97pub fn check_pose(msg: &Value) -> Vec<Clause> {
98    let is_pose = msg.get("t").and_then(Value::as_str) == Some("pose");
99    let quat = floats(msg, "r", 4);
100    let has_orientation = quat.is_some() || msg.get("y").and_then(Value::as_f64).is_some();
101    // Unit-quaternion check only applies when a full quaternion was sent.
102    let unit_quat = match &quat {
103        Some(r) => {
104            let len2 = r.iter().map(|x| x * x).sum::<f64>();
105            (len2 - 1.0).abs() < 0.05
106        }
107        None => true, // yaw-only or absent → not applicable, don't penalize
108    };
109
110    vec![
111        Clause {
112            name: "pose tagged t=pose",
113            severity: Severity::Error,
114            pass: is_pose,
115            notes: if is_pose {
116                vec![]
117            } else {
118                vec![format!("t = {:?}", msg.get("t"))]
119            },
120        },
121        Clause {
122            name: "pose carries an occupant id",
123            severity: Severity::Error,
124            pass: msg.get("id").and_then(Value::as_u64).is_some(),
125            notes: vec![],
126        },
127        Clause {
128            name: "pose is server-stamped (ts)",
129            severity: Severity::Error,
130            pass: msg.get("ts").and_then(Value::as_u64).is_some(),
131            notes: vec!["ts is mandatory: the single clock source for interpolation".into()],
132        },
133        Clause {
134            name: "pose has a position [f32;3]",
135            severity: Severity::Error,
136            pass: floats(msg, "p", 3).is_some(),
137            notes: vec![],
138        },
139        Clause {
140            name: "pose has a velocity [f32;3]",
141            severity: Severity::Error,
142            pass: floats(msg, "v", 3).is_some(),
143            notes: vec!["v is mandatory: enables gap extrapolation".into()],
144        },
145        Clause {
146            name: "pose has an orientation (r xyzw or yaw y)",
147            severity: Severity::Error,
148            pass: has_orientation,
149            notes: vec![],
150        },
151        Clause {
152            name: "orientation is a unit quaternion",
153            severity: Severity::Warn,
154            pass: unit_quat,
155            notes: vec![],
156        },
157        Clause {
158            name: "pose carries an animation state (a)",
159            severity: Severity::Warn,
160            pass: msg.get("a").and_then(Value::as_u64).is_some(),
161            notes: vec![],
162        },
163    ]
164}
165
166/// The result of a live relay probe.
167pub struct RelayOutcome {
168    /// Whether the WebSocket connected at all.
169    pub connected: bool,
170    pub clauses: Vec<Clause>,
171    /// Free-form observations (occupant count, whether a pose was seen, …).
172    pub notes: Vec<String>,
173}
174
175/// Connect to `url` (`wss://<relay>/thread/<worldId>`), `join` with `passport`, and
176/// run the wire validators on what the relay sends back. Best-effort: with a single
177/// probe client the relay may not fan a pose back (area-of-interest / no peers), so
178/// a missing pose is a note, not a failure — the pinned validators carry the spec.
179pub async fn probe(url: &str, passport: &str, timeout_ms: u64) -> RelayOutcome {
180    use futures_util::{SinkExt, StreamExt};
181    use tokio_tungstenite::tungstenite::Message;
182
183    let mut clauses = Vec::new();
184    let mut notes = Vec::new();
185
186    let ws = match tokio_tungstenite::connect_async(url).await {
187        Ok((ws, _)) => ws,
188        Err(e) => {
189            clauses.push(Clause {
190                name: "relay reachable (WebSocket)",
191                severity: Severity::Error,
192                pass: false,
193                notes: vec![e.to_string()],
194            });
195            return RelayOutcome {
196                connected: false,
197                clauses,
198                notes,
199            };
200        }
201    };
202    clauses.push(Clause {
203        name: "relay reachable (WebSocket)",
204        severity: Severity::Error,
205        pass: true,
206        notes: vec![],
207    });
208
209    let (mut write, mut read) = ws.split();
210    let join = serde_json::json!({ "t": "join", "passport": passport, "spawn": null });
211    let _ = write.send(Message::Text(join.to_string())).await;
212
213    // Also emit one pose so a relay that echoes gives us something to validate.
214    let pose = serde_json::json!({
215        "t": "pose", "id": 0, "ts": 0, "p": [0.0, 0.0, 0.0], "r": [0.0, 0.0, 0.0, 1.0], "v": [0.0, 0.0, 0.0], "a": 0
216    });
217    let _ = write.send(Message::Text(pose.to_string())).await;
218
219    let deadline = tokio::time::Duration::from_millis(timeout_ms);
220    let mut saw_welcome = false;
221    let mut saw_pose = false;
222
223    let _ = tokio::time::timeout(deadline, async {
224        while let Some(Ok(msg)) = read.next().await {
225            let Ok(text) = msg.into_text() else { continue };
226            let Ok(v) = serde_json::from_str::<Value>(&text) else {
227                continue;
228            };
229            match v.get("t").and_then(Value::as_str) {
230                Some("welcome") if !saw_welcome => {
231                    saw_welcome = true;
232                    clauses.extend(check_welcome(&v));
233                    if let Some(occ) = v.get("occupants").and_then(Value::as_array) {
234                        notes.push(format!("relay reports {} occupant(s)", occ.len()));
235                    }
236                }
237                Some("pose") if !saw_pose => {
238                    saw_pose = true;
239                    clauses.extend(check_pose(&v));
240                }
241                _ => {}
242            }
243            if saw_welcome && saw_pose {
244                break;
245            }
246        }
247    })
248    .await;
249
250    if !saw_welcome {
251        clauses.push(Clause {
252            name: "relay replies welcome on join",
253            severity: Severity::Error,
254            pass: false,
255            notes: vec!["no welcome within timeout (Passport rejected? wrong path?)".into()],
256        });
257    }
258    if !saw_pose {
259        notes.push("no pose fan-out observed (expected with a lone probe client)".into());
260    }
261
262    // A second connection, watching rather than entering. Done on its own
263    // socket because that is how a status bar or a directory actually reads a
264    // world — and because the interesting property is one a joined client
265    // cannot test: that the watcher is *not* in the room it can see.
266    match tokio_tungstenite::connect_async(url).await {
267        Ok((ws2, _)) => {
268            let (mut w2, mut r2) = ws2.split();
269            let _ = w2
270                .send(Message::Text(serde_json::json!({ "t": "observe" }).to_string()))
271                .await;
272            let mut welcomed = false;
273            let mut poses_leaked = 0usize;
274            let _ = tokio::time::timeout(deadline, async {
275                while let Some(Ok(msg)) = r2.next().await {
276                    let Ok(text) = msg.into_text() else { continue };
277                    let Ok(v) = serde_json::from_str::<Value>(&text) else { continue };
278                    match v.get("t").and_then(Value::as_str) {
279                        Some("welcome") if !welcomed => {
280                            welcomed = true;
281                            clauses.extend(check_observer_welcome(&v));
282                        }
283                        Some("pose") => poses_leaked += 1,
284                        _ => {}
285                    }
286                }
287            })
288            .await;
289            if welcomed {
290                clauses.push(Clause {
291                    name: "observers are sent no poses",
292                    severity: Severity::Error,
293                    pass: poses_leaked == 0,
294                    notes: if poses_leaked == 0 {
295                        vec![]
296                    } else {
297                        vec![format!("{poses_leaked} pose frame(s) reached a watcher")]
298                    },
299                });
300            } else {
301                notes.push(
302                    "relay did not answer `observe` — watching without entering is unsupported \
303                     (presence-wire §2)"
304                        .into(),
305                );
306            }
307        }
308        Err(e) => notes.push(format!("could not open a second socket to watch: {e}")),
309    }
310
311    RelayOutcome {
312        connected: true,
313        clauses,
314        notes,
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::clauses_pass;
322    use serde_json::json;
323
324/// The observe verb's own shape. Written from an outside implementer's
325    /// probe — a third language, against the live relay — which found the
326    /// roster populated on the first frame; that property is the difference
327    /// between a status bar that knows who is in a room and one that only
328    /// learns about the next arrival.
329    #[test]
330    fn a_spec_observer_welcome_passes_and_a_joiners_does_not() {
331        let w = json!({
332            "t": "welcome", "id": 0, "observer": true,
333            "occupants": [{ "id": 1 }], "tick_hz": 15
334        });
335        let clauses = check_observer_welcome(&w);
336        assert!(clauses_pass(&clauses), "{clauses:?}");
337        assert_eq!(
338            w["occupants"].as_array().unwrap().len(),
339            1,
340            "the roster stands as it is, not as it will be"
341        );
342
343        // A relay that answers `observe` with an ordinary welcome has given the
344        // watcher a body: it cannot tell it is observing, and it holds an
345        // occupant id somebody else's roster will disagree about.
346        let joiner = json!({ "t": "welcome", "id": 3, "occupants": [], "tick_hz": 15 });
347        let clauses = check_observer_welcome(&joiner);
348        assert!(!clauses_pass(&clauses));
349        assert!(clauses.iter().any(|c| c.name == "observe welcome is marked observer" && !c.pass));
350        assert!(clauses.iter().any(|c| c.name == "observer is not an occupant (id 0)" && !c.pass));
351    }
352
353    #[test]
354    fn a_spec_welcome_passes() {
355        let w = json!({ "t": "welcome", "id": 5, "occupants": [{ "id": 7 }, { "id": 9 }], "tick_hz": 15 });
356        let clauses = check_welcome(&w);
357        assert!(clauses_pass(&clauses), "{clauses:?}");
358        assert!(clauses.iter().all(|c| c.pass));
359    }
360
361    #[test]
362    fn a_welcome_without_an_id_fails() {
363        let w = json!({ "t": "welcome", "occupants": [] });
364        let clauses = check_welcome(&w);
365        assert!(!clauses_pass(&clauses));
366        let id = clauses
367            .iter()
368            .find(|c| c.name == "welcome assigns an occupant id")
369            .unwrap();
370        assert!(!id.pass);
371        assert_eq!(id.severity, Severity::Error);
372    }
373
374    #[test]
375    fn a_full_spec_pose_passes() {
376        let p = json!({ "t": "pose", "id": 7, "ts": 123456, "p": [3.0, 0.0, -1.0], "r": [0.0, 0.0, 0.0, 1.0], "v": [1.0, 0.0, 0.0], "a": 2 });
377        let clauses = check_pose(&p);
378        assert!(clauses_pass(&clauses));
379        assert!(clauses.iter().all(|c| c.pass), "{clauses:?}");
380    }
381
382    #[test]
383    fn a_pose_missing_ts_or_velocity_is_an_error() {
384        let no_ts = json!({ "t": "pose", "id": 7, "p": [0.0, 0.0, 0.0], "r": [0.0, 0.0, 0.0, 1.0], "v": [0.0, 0.0, 0.0], "a": 0 });
385        assert!(!clauses_pass(&check_pose(&no_ts)), "ts is mandatory");
386
387        let no_v = json!({ "t": "pose", "id": 7, "ts": 1, "p": [0.0, 0.0, 0.0], "r": [0.0, 0.0, 0.0, 1.0], "a": 0 });
388        assert!(!clauses_pass(&check_pose(&no_v)), "v is mandatory");
389    }
390
391    #[test]
392    fn yaw_only_orientation_is_accepted() {
393        // §3 permits a yaw-only pose (`y`) instead of a full quaternion.
394        let p = json!({ "t": "pose", "id": 7, "ts": 1, "p": [0.0, 0.0, 0.0], "y": 1.57, "v": [0.0, 0.0, 0.0], "a": 1 });
395        let clauses = check_pose(&p);
396        assert!(clauses_pass(&clauses), "{clauses:?}");
397        let orient = clauses
398            .iter()
399            .find(|c| c.name.contains("orientation is a unit"))
400            .unwrap();
401        assert!(
402            orient.pass,
403            "unit-quat check must not penalize a yaw-only pose"
404        );
405    }
406
407    #[test]
408    fn a_non_unit_quaternion_warns_but_does_not_fail() {
409        let p = json!({ "t": "pose", "id": 7, "ts": 1, "p": [0.0, 0.0, 0.0], "r": [1.0, 1.0, 0.0, 0.0], "v": [0.0, 0.0, 0.0], "a": 0 });
410        let clauses = check_pose(&p);
411        assert!(
412            clauses_pass(&clauses),
413            "non-unit quat is a warning, not a failure"
414        );
415        let unit = clauses
416            .iter()
417            .find(|c| c.name.contains("unit quaternion"))
418            .unwrap();
419        assert!(!unit.pass);
420        assert_eq!(unit.severity, Severity::Warn);
421    }
422}