Skip to main content

thread_conformance/
rendezvous.rs

1//! Rendezvous conformance — does a P2P rendezvous speak [presence-topology-v0.1] §3.2?
2//!
3//! [presence-topology-v0.1]: https://github.com/Pixygon/thread-spec/blob/main/specs/presence-topology-v0.1.md
4//!
5//! Same shape as the relay checker: **pure validators** ([`check_welcome`],
6//! [`check_peers`]) pin the wire rules with no network, and [`probe`] is the thin
7//! live driver — it announces to a room and validates the introduction sequence the
8//! rendezvous sends back (`welcome` **before** `peers`; the ordering contract
9//! clients rely on to adopt their assigned id before negotiating).
10
11use serde_json::Value;
12
13use crate::{Clause, Severity};
14
15/// Validate a rendezvous `welcome` message (rv→client, in reply to `announce`).
16/// Distinct from a relay's welcome: just `{t:"welcome", id:<u32>}` — no occupants,
17/// no tick_hz (the rendezvous never sees a pose, so it has no rate to suggest).
18pub fn check_welcome(msg: &Value) -> Vec<Clause> {
19    let is_welcome = msg.get("t").and_then(Value::as_str) == Some("welcome");
20    vec![
21        Clause {
22            name: "welcome tagged t=welcome",
23            severity: Severity::Error,
24            pass: is_welcome,
25            notes: if is_welcome {
26                vec![]
27            } else {
28                vec![format!("t = {:?}", msg.get("t"))]
29            },
30        },
31        Clause {
32            name: "welcome assigns a peer id",
33            severity: Severity::Error,
34            pass: msg.get("id").and_then(Value::as_u64).is_some(),
35            notes: vec!["clients MUST adopt the assigned id before negotiating".into()],
36        },
37    ]
38}
39
40/// Validate a `peers` message (rv→client: who's present, or a join delta).
41pub fn check_peers(msg: &Value) -> Vec<Clause> {
42    let is_peers = msg.get("t").and_then(Value::as_str) == Some("peers");
43    let all_ids = msg
44        .get("peers")
45        .and_then(Value::as_array)
46        .map(|a| a.iter().all(|p| p.as_u64().is_some()))
47        .unwrap_or(false);
48    vec![
49        Clause {
50            name: "peers tagged t=peers",
51            severity: Severity::Error,
52            pass: is_peers,
53            notes: if is_peers {
54                vec![]
55            } else {
56                vec![format!("t = {:?}", msg.get("t"))]
57            },
58        },
59        Clause {
60            name: "peers is an array of peer ids",
61            severity: Severity::Error,
62            pass: all_ids,
63            notes: vec![],
64        },
65    ]
66}
67
68/// The result of a live rendezvous probe.
69pub struct RendezvousOutcome {
70    /// Whether the WebSocket connected at all.
71    pub connected: bool,
72    pub clauses: Vec<Clause>,
73    /// Free-form observations (peer count, whether welcome was assigned, …).
74    pub notes: Vec<String>,
75}
76
77/// Connect to `url` (`wss://<host>/rtc/<key>`), `announce`, and validate the
78/// introduction the rendezvous sends back. Per §3.2 `welcome` is OPTIONAL (a
79/// minimal rendezvous may let clients keep provisional ids) but `peers` is not;
80/// and when `welcome` IS sent, it must arrive **before** `peers`.
81pub async fn probe(url: &str, timeout_ms: u64) -> RendezvousOutcome {
82    use futures_util::{SinkExt, StreamExt};
83    use tokio_tungstenite::tungstenite::Message;
84
85    let mut clauses = Vec::new();
86    let mut notes = Vec::new();
87
88    let ws = match tokio_tungstenite::connect_async(url).await {
89        Ok((ws, _)) => ws,
90        Err(e) => {
91            clauses.push(Clause {
92                name: "rendezvous reachable (WebSocket)",
93                severity: Severity::Error,
94                pass: false,
95                notes: vec![e.to_string()],
96            });
97            return RendezvousOutcome {
98                connected: false,
99                clauses,
100                notes,
101            };
102        }
103    };
104    clauses.push(Clause {
105        name: "rendezvous reachable (WebSocket)",
106        severity: Severity::Error,
107        pass: true,
108        notes: vec![],
109    });
110
111    let (mut write, mut read) = ws.split();
112    let announce =
113        serde_json::json!({ "t": "announce", "peer": 4242, "world": "conformance-probe" });
114    let _ = write.send(Message::Text(announce.to_string())).await;
115
116    let deadline = tokio::time::Duration::from_millis(timeout_ms);
117    let mut saw_welcome = false;
118    let mut saw_peers = false;
119
120    let _ = tokio::time::timeout(deadline, async {
121        while let Some(Ok(msg)) = read.next().await {
122            let Ok(text) = msg.into_text() else { continue };
123            let Ok(v) = serde_json::from_str::<Value>(&text) else {
124                continue;
125            };
126            match v.get("t").and_then(Value::as_str) {
127                Some("welcome") if !saw_welcome => {
128                    saw_welcome = true;
129                    clauses.extend(check_welcome(&v));
130                    // The ordering contract: welcome must precede peers.
131                    clauses.push(Clause {
132                        name: "welcome arrives before peers",
133                        severity: Severity::Error,
134                        pass: !saw_peers,
135                        notes: vec![],
136                    });
137                }
138                Some("peers") if !saw_peers => {
139                    saw_peers = true;
140                    clauses.extend(check_peers(&v));
141                    if let Some(p) = v.get("peers").and_then(Value::as_array) {
142                        notes.push(format!("room reports {} peer(s) present", p.len()));
143                    }
144                }
145                _ => {}
146            }
147            if saw_peers {
148                break; // peers is the last message of the introduction
149            }
150        }
151    })
152    .await;
153
154    if !saw_peers {
155        clauses.push(Clause {
156            name: "rendezvous replies peers on announce",
157            severity: Severity::Error,
158            pass: false,
159            notes: vec!["no peers within timeout (wrong path? not a rendezvous?)".into()],
160        });
161    }
162    if !saw_welcome {
163        notes.push(
164            "no welcome{id} — minimal rendezvous, clients keep provisional ids (allowed)".into(),
165        );
166    }
167
168    RendezvousOutcome {
169        connected: true,
170        clauses,
171        notes,
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::clauses_pass;
179    use serde_json::json;
180
181    #[test]
182    fn a_spec_welcome_passes() {
183        let w = json!({ "t": "welcome", "id": 3 });
184        let clauses = check_welcome(&w);
185        assert!(clauses_pass(&clauses), "{clauses:?}");
186        assert!(clauses.iter().all(|c| c.pass));
187    }
188
189    #[test]
190    fn a_welcome_without_an_id_fails() {
191        let clauses = check_welcome(&json!({ "t": "welcome" }));
192        assert!(!clauses_pass(&clauses));
193    }
194
195    #[test]
196    fn spec_peers_messages_pass_including_the_empty_room_and_the_join_delta() {
197        for p in [
198            json!({ "t": "peers", "peers": [] }),
199            json!({ "t": "peers", "peers": [1, 2] }),
200            json!({ "t": "peers", "peers": [7] }),
201        ] {
202            let clauses = check_peers(&p);
203            assert!(clauses_pass(&clauses), "{p} → {clauses:?}");
204        }
205    }
206
207    #[test]
208    fn malformed_peers_fail() {
209        // Not an array of ids.
210        assert!(!clauses_pass(&check_peers(
211            &json!({ "t": "peers", "peers": ["a"] })
212        )));
213        assert!(!clauses_pass(&check_peers(&json!({ "t": "peers" }))));
214    }
215}