Skip to main content

dora_cli/
ws_client.rs

1//! WebSocket client for CLI-to-coordinator communication.
2//!
3//! Replaces `TcpRequestReplyConnection` with a single WS connection that handles
4//! both request-reply and log streaming.
5
6use dora_message::ws_protocol::WsRequest;
7use eyre::{Context, eyre};
8use futures::{SinkExt, StreamExt};
9use std::{collections::HashMap, net::SocketAddr, sync::mpsc as std_mpsc};
10use tokio::sync::{mpsc, oneshot};
11use tokio_tungstenite::tungstenite::Message;
12use uuid::Uuid;
13
14/// Helper for deserializing incoming WS frames without going through
15/// `serde_json::Value` for the result/payload fields: they are handed to the
16/// caller as raw bytes to be parsed into their real type, so they are never
17/// re-serialized from an intermediate `Value`.
18#[derive(serde::Deserialize)]
19struct IncomingFrame {
20    #[serde(default)]
21    id: Option<Uuid>,
22    #[serde(default)]
23    event: Option<String>,
24    #[serde(default)]
25    result: Option<Box<serde_json::value::RawValue>>,
26    #[serde(default)]
27    error: Option<String>,
28    #[serde(default)]
29    payload: Option<Box<serde_json::value::RawValue>>,
30}
31
32/// A WebSocket session to the coordinator.
33///
34/// Provides synchronous `request()` for request-reply and `subscribe_logs()`
35/// for streaming log events, both over the same WS connection.
36pub struct WsSession {
37    rt: tokio::runtime::Runtime,
38    cmd_tx: mpsc::UnboundedSender<SessionCommand>,
39}
40
41enum SessionCommand {
42    /// Send a request and wait for a response.
43    Request {
44        data: Vec<u8>,
45        reply: oneshot::Sender<eyre::Result<Vec<u8>>>,
46    },
47    /// Subscribe to log/build-log events.
48    SubscribeLogs {
49        request: Vec<u8>,
50        log_tx: std_mpsc::Sender<eyre::Result<Vec<u8>>>,
51        ack_tx: oneshot::Sender<eyre::Result<()>>,
52    },
53    /// Subscribe to topic data via binary WS frames.
54    SubscribeTopics {
55        request: Vec<u8>,
56        data_tx: std_mpsc::Sender<eyre::Result<Vec<u8>>>,
57        ack_tx: oneshot::Sender<eyre::Result<Uuid>>,
58    },
59}
60
61impl WsSession {
62    /// Connect to the coordinator via WebSocket.
63    ///
64    /// If called from within an existing tokio runtime, uses that runtime.
65    /// Otherwise creates a dedicated runtime with one worker thread so the WS
66    /// receive loop keeps running after synchronous API calls return.
67    pub fn connect(addr: SocketAddr) -> eyre::Result<Self> {
68        if tokio::runtime::Handle::try_current().is_ok() {
69            eyre::bail!(
70                "WsSession::connect must not be called from within an async context; \
71                 use an async-native client instead"
72            );
73        }
74        let rt = tokio::runtime::Builder::new_multi_thread()
75            .worker_threads(1)
76            .enable_all()
77            .build()
78            .context("failed to create tokio runtime for WS session")?;
79
80        let ws_url = format!("ws://{addr}/api/control");
81        let ws_stream = rt
82            .block_on(async {
83                use tokio_tungstenite::tungstenite;
84                let mut request = tungstenite::http::Request::builder()
85                    .uri(&ws_url)
86                    .header("Host", addr.to_string())
87                    .header("Connection", "Upgrade")
88                    .header("Upgrade", "websocket")
89                    .header(
90                        "Sec-WebSocket-Key",
91                        tungstenite::handshake::client::generate_key(),
92                    )
93                    .header("Sec-WebSocket-Version", "13");
94                if let Some(token) = dora_message::auth::discover_token() {
95                    request = request.header("Authorization", format!("Bearer {}", token.as_hex()));
96                }
97                let request = request.body(()).expect("failed to build WS request");
98                tokio_tungstenite::connect_async(request).await
99            })
100            .map_err(|e| {
101                let msg = e.to_string();
102                if msg.to_lowercase().contains("connection refused")
103                    || msg.contains("No connection could be made")
104                {
105                    eyre!(
106                        "cannot connect to coordinator at {addr}: {msg}\n\n  \
107                         hint: is the coordinator running? Start it with `dora up`"
108                    )
109                } else if msg.contains("401") || msg.contains("Unauthorized") {
110                    eyre!(
111                        "authentication failed connecting to coordinator at {addr}: {msg}\n\n  \
112                         The coordinator was started with --auth and requires a valid token.\n  \
113                         The token is stored in ~/.config/dora/.dora-token\n\n  \
114                         Possible fixes:\n  \
115                         - Run `dora down && dora up` to regenerate the token\n  \
116                         - Ensure you're using the same user that started the coordinator\n  \
117                         - Set DORA_AUTH_TOKEN env var to match the coordinator's token\n  \
118                         - Restart without --auth to disable authentication"
119                    )
120                } else {
121                    eyre!("failed to connect to coordinator at {addr}: {msg}")
122                }
123            })?
124            .0;
125
126        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
127        rt.spawn(session_loop(ws_stream, cmd_rx));
128
129        let session = Self { rt, cmd_tx };
130
131        // Protocol version handshake — sent before any other request.
132        // Fails fast on version mismatch so the CLI never silently
133        // exchanges incompatible messages with the coordinator
134        // (dora-rs/adora#151).
135        session.handshake_hello()?;
136
137        Ok(session)
138    }
139
140    /// Send a `ControlRequest::Hello` stamped with the CLI's dora
141    /// crate version and verify the coordinator accepts it. Fails with
142    /// a clear error on version mismatch.
143    fn handshake_hello(&self) -> eyre::Result<()> {
144        use dora_message::{
145            cli_to_coordinator::ControlRequest, coordinator_to_cli::ControlRequestReply,
146        };
147        let req = serde_json::to_vec(&ControlRequest::hello())
148            .map_err(|e| eyre!("failed to serialize Hello: {e}"))?;
149        let raw_reply = self.request(&req).wrap_err(
150            "protocol version handshake with coordinator failed \
151             (could not send or receive Hello)",
152        )?;
153        let reply: ControlRequestReply = serde_json::from_slice(&raw_reply)
154            .map_err(|e| eyre!("failed to parse Hello reply: {e}"))?;
155        match reply {
156            ControlRequestReply::HelloOk { dora_version } => {
157                tracing::debug!(
158                    coordinator_version = %dora_version,
159                    "protocol version handshake OK"
160                );
161                Ok(())
162            }
163            ControlRequestReply::Error(msg) => Err(eyre!(
164                "coordinator rejected CLI: {msg}\n\n  \
165                 hint: the CLI and coordinator binaries must share a \
166                 semver-compatible dora version. Upgrade the component \
167                 that is behind."
168            )),
169            other => Err(eyre!(
170                "unexpected reply to Hello: {other:?} — \
171                 coordinator may be too old to understand the handshake"
172            )),
173        }
174    }
175
176    /// Send a request and wait synchronously for the reply.
177    ///
178    /// `data` should be a serialized `ControlRequest`.
179    /// Returns the serialized `ControlRequestReply`.
180    pub fn request(&self, data: &[u8]) -> eyre::Result<Vec<u8>> {
181        let (reply_tx, reply_rx) = oneshot::channel();
182        self.cmd_tx
183            .send(SessionCommand::Request {
184                data: data.to_vec(),
185                reply: reply_tx,
186            })
187            .map_err(|_| eyre!("WS session closed"))?;
188
189        self.rt
190            .block_on(reply_rx)
191            .map_err(|_| eyre!("WS session dropped reply"))?
192    }
193
194    /// Subscribe to topic data via the coordinator's topic inspection stream.
195    ///
196    /// Sends a `TopicSubscribe` request, waits for the ack, then returns
197    /// a `(subscription_id, receiver)` pair. Binary WS frames with matching
198    /// subscription UUID prefix are dispatched to the receiver.
199    pub fn subscribe_topics(
200        &self,
201        dataflow_id: Uuid,
202        topics: Vec<(dora_message::id::NodeId, dora_message::id::DataId)>,
203    ) -> eyre::Result<(Uuid, std_mpsc::Receiver<eyre::Result<Vec<u8>>>)> {
204        let request = serde_json::to_vec(
205            &dora_message::cli_to_coordinator::ControlRequest::TopicSubscribe {
206                dataflow_id,
207                topics,
208                protocol_version: Some(dora_message::TOPIC_DATA_PROTOCOL_VERSION),
209            },
210        )
211        .map_err(|e| eyre!("failed to serialize TopicSubscribe: {e}"))?;
212
213        let (data_tx, data_rx) = std_mpsc::channel();
214        let (ack_tx, ack_rx) = oneshot::channel();
215        self.cmd_tx
216            .send(SessionCommand::SubscribeTopics {
217                request,
218                data_tx,
219                ack_tx,
220            })
221            .map_err(|_| eyre!("WS session closed"))?;
222
223        let subscription_id = self
224            .rt
225            .block_on(ack_rx)
226            .map_err(|_| eyre!("WS session dropped ack"))??;
227
228        Ok((subscription_id, data_rx))
229    }
230
231    /// Subscribe to log events on this connection.
232    ///
233    /// Sends the subscribe request (LogSubscribe or BuildLogSubscribe),
234    /// waits for the ack, then returns a receiver for log event payloads.
235    ///
236    /// Each received item is the serialized `LogMessage`.
237    pub fn subscribe_logs(
238        &self,
239        request: &[u8],
240    ) -> eyre::Result<std_mpsc::Receiver<eyre::Result<Vec<u8>>>> {
241        let (log_tx, log_rx) = std_mpsc::channel();
242        let (ack_tx, ack_rx) = oneshot::channel();
243        self.cmd_tx
244            .send(SessionCommand::SubscribeLogs {
245                request: request.to_vec(),
246                log_tx,
247                ack_tx,
248            })
249            .map_err(|_| eyre!("WS session closed"))?;
250
251        self.rt
252            .block_on(ack_rx)
253            .map_err(|_| eyre!("WS session dropped ack"))??;
254
255        Ok(log_rx)
256    }
257}
258
259type WsStream =
260    tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
261type PendingRequests = HashMap<Uuid, oneshot::Sender<eyre::Result<Vec<u8>>>>;
262type PendingSubscribes = HashMap<
263    Uuid,
264    (
265        oneshot::Sender<eyre::Result<()>>,
266        std_mpsc::Sender<eyre::Result<Vec<u8>>>,
267    ),
268>;
269type PendingTopicSubscribes = HashMap<
270    Uuid,
271    (
272        oneshot::Sender<eyre::Result<Uuid>>,
273        std_mpsc::Sender<eyre::Result<Vec<u8>>>,
274    ),
275>;
276type TopicSubscribers = HashMap<Uuid, std_mpsc::Sender<eyre::Result<Vec<u8>>>>;
277
278async fn session_loop(ws_stream: WsStream, mut cmd_rx: mpsc::UnboundedReceiver<SessionCommand>) {
279    let (mut ws_tx, mut ws_rx) = ws_stream.split();
280    let mut pending_requests: PendingRequests = HashMap::new();
281    let mut pending_subscribes: PendingSubscribes = HashMap::new();
282    let mut log_subscribers: Vec<std_mpsc::Sender<eyre::Result<Vec<u8>>>> = Vec::new();
283    let mut pending_topic_subscribes: PendingTopicSubscribes = HashMap::new();
284    let mut topic_subscribers: TopicSubscribers = HashMap::new();
285
286    loop {
287        tokio::select! {
288            Some(cmd) = cmd_rx.recv() => {
289                match cmd {
290                    SessionCommand::Request { data, reply } => {
291                        let id = Uuid::new_v4();
292                        let params = match serde_json::from_slice(&data) {
293                            Ok(v) => v,
294                            Err(e) => {
295                                let _ = reply.send(Err(eyre!("failed to parse request: {e}")));
296                                continue;
297                            }
298                        };
299                        let req = WsRequest {
300                            id,
301                            method: "control".to_string(),
302                            params,
303                        };
304                        let json = match serde_json::to_string(&req) {
305                            Ok(j) => j,
306                            Err(e) => {
307                                let _ = reply.send(Err(eyre!("failed to serialize WsRequest: {e}")));
308                                continue;
309                            }
310                        };
311                        pending_requests.insert(id, reply);
312                        if ws_tx.send(Message::Text(json.into())).await.is_err() {
313                            break;
314                        }
315                    }
316                    SessionCommand::SubscribeLogs { request, log_tx, ack_tx } => {
317                        let id = Uuid::new_v4();
318                        let params = match serde_json::from_slice(&request) {
319                            Ok(v) => v,
320                            Err(e) => {
321                                let _ = ack_tx.send(Err(eyre!("failed to parse subscribe request: {e}")));
322                                continue;
323                            }
324                        };
325                        let req = WsRequest {
326                            id,
327                            method: "control".to_string(),
328                            params,
329                        };
330                        let json = match serde_json::to_string(&req) {
331                            Ok(j) => j,
332                            Err(e) => {
333                                let _ = ack_tx.send(Err(eyre!("failed to serialize WsRequest: {e}")));
334                                continue;
335                            }
336                        };
337                        pending_subscribes.insert(id, (ack_tx, log_tx));
338                        if ws_tx.send(Message::Text(json.into())).await.is_err() {
339                            break;
340                        }
341                    }
342                    SessionCommand::SubscribeTopics { request, data_tx, ack_tx } => {
343                        let id = Uuid::new_v4();
344                        let params = match serde_json::from_slice(&request) {
345                            Ok(v) => v,
346                            Err(e) => {
347                                let _ = ack_tx.send(Err(eyre!("failed to parse topic subscribe request: {e}")));
348                                continue;
349                            }
350                        };
351                        let req = WsRequest {
352                            id,
353                            method: "control".to_string(),
354                            params,
355                        };
356                        let json = match serde_json::to_string(&req) {
357                            Ok(j) => j,
358                            Err(e) => {
359                                let _ = ack_tx.send(Err(eyre!("failed to serialize WsRequest: {e}")));
360                                continue;
361                            }
362                        };
363                        pending_topic_subscribes.insert(id, (ack_tx, data_tx));
364                        if ws_tx.send(Message::Text(json.into())).await.is_err() {
365                            break;
366                        }
367                    }
368                }
369            }
370            msg = ws_rx.next() => {
371                let Some(msg) = msg else { break };
372                match msg {
373                    Ok(Message::Text(text)) => {
374                        let frame: IncomingFrame = match serde_json::from_str(&text) {
375                            Ok(m) => m,
376                            Err(e) => {
377                                tracing::warn!("failed to parse WS message: {e}");
378                                continue;
379                            }
380                        };
381
382                        if let Some(event_name) = &frame.event {
383                            if event_name == "log"
384                                && let Some(payload) = &frame.payload {
385                                    let bytes = payload.get().as_bytes().to_vec();
386                                    log_subscribers.retain(|tx| tx.send(Ok(bytes.clone())).is_ok());
387                                }
388                        } else if let Some(id) = frame.id {
389                            handle_response(
390                                id,
391                                frame.result,
392                                frame.error,
393                                &mut pending_requests,
394                                &mut pending_subscribes,
395                                &mut log_subscribers,
396                                &mut pending_topic_subscribes,
397                                &mut topic_subscribers,
398                            );
399                        }
400                    }
401                    Ok(Message::Binary(data)) => {
402                        // Binary frame: first 16 bytes = subscription UUID, rest = payload
403                        if data.len() < 16 {
404                            tracing::warn!("binary WS frame too short ({} bytes)", data.len());
405                            continue;
406                        }
407                        let Ok(sub_id_bytes): Result<[u8; 16], _> = data[..16].try_into() else {
408                            continue;
409                        };
410                        let sub_id = Uuid::from_bytes(sub_id_bytes);
411                        let payload = data[16..].to_vec();
412                        if let Some(tx) = topic_subscribers.get(&sub_id)
413                            && tx.send(Ok(payload)).is_err() {
414                                topic_subscribers.remove(&sub_id);
415                            }
416                    }
417                    Ok(Message::Close(_)) => break,
418                    Ok(Message::Ping(data)) => {
419                        let _ = ws_tx.send(Message::Pong(data)).await;
420                    }
421                    Ok(other) => {
422                        tracing::trace!("ignoring unexpected WS message type: {other:?}");
423                    }
424                    Err(_) => break,
425                }
426            }
427        }
428    }
429
430    // Clean up: notify pending requests of disconnect
431    for (_, reply) in pending_requests.drain() {
432        let _ = reply.send(Err(eyre!("WS connection closed")));
433    }
434    for (_, (ack, _)) in pending_subscribes.drain() {
435        let _ = ack.send(Err(eyre!("WS connection closed")));
436    }
437    for (_, (ack, _)) in pending_topic_subscribes.drain() {
438        let _ = ack.send(Err(eyre!("WS connection closed")));
439    }
440}
441
442/// Error for a `TopicSubscribed` ack whose binary-frame encoding does not match
443/// ours. Wraps the shared message so both sides of the handshake explain a
444/// mismatch identically.
445fn topic_protocol_mismatch_error(coordinator_version: Option<u16>) -> eyre::Report {
446    eyre!(dora_message::topic_protocol_mismatch_message(
447        "coordinator",
448        coordinator_version
449    ))
450}
451
452#[allow(clippy::too_many_arguments)]
453fn handle_response(
454    id: Uuid,
455    result: Option<Box<serde_json::value::RawValue>>,
456    error: Option<String>,
457    pending_requests: &mut PendingRequests,
458    pending_subscribes: &mut PendingSubscribes,
459    log_subscribers: &mut Vec<std_mpsc::Sender<eyre::Result<Vec<u8>>>>,
460    pending_topic_subscribes: &mut PendingTopicSubscribes,
461    topic_subscribers: &mut TopicSubscribers,
462) {
463    // Check if this is a log subscribe ack
464    if let Some((ack_tx, log_tx)) = pending_subscribes.remove(&id) {
465        if let Some(error) = error {
466            let _ = ack_tx.send(Err(eyre!("{error}")));
467        } else {
468            log_subscribers.push(log_tx);
469            let _ = ack_tx.send(Ok(()));
470        }
471        return;
472    }
473
474    // Check if this is a topic subscribe ack
475    if let Some((ack_tx, data_tx)) = pending_topic_subscribes.remove(&id) {
476        if let Some(error) = error {
477            let _ = ack_tx.send(Err(eyre!("{error}")));
478        } else if let Some(raw) = &result {
479            // Parse TopicSubscribed { subscription_id } from the result
480            let reply: Result<dora_message::coordinator_to_cli::ControlRequestReply, _> =
481                serde_json::from_str(raw.get());
482            match reply {
483                Ok(dora_message::coordinator_to_cli::ControlRequestReply::TopicSubscribed {
484                    subscription_id,
485                    protocol_version,
486                }) => {
487                    // A coordinator on the other encoding would send frames we
488                    // would misparse rather than fail on, so refuse the
489                    // subscription instead of consuming them (dora-rs/dora#3153).
490                    //
491                    // We deliberately don't send `TopicUnsubscribe` here. Only
492                    // a *pre-handshake* coordinator reaches this branch having
493                    // actually created a subscription (a current one rejects
494                    // before creating anything), and every `subscribe_topics`
495                    // caller propagates this error with `?`, dropping the
496                    // `WsSession` — which closes the connection and makes the
497                    // coordinator tear its subscriptions down. Threading an
498                    // outgoing sender into `handle_response` to save that
499                    // already-closing subscription would not earn its keep.
500                    if protocol_version != Some(dora_message::TOPIC_DATA_PROTOCOL_VERSION) {
501                        let _ = ack_tx.send(Err(topic_protocol_mismatch_error(protocol_version)));
502                    } else {
503                        topic_subscribers.insert(subscription_id, data_tx);
504                        let _ = ack_tx.send(Ok(subscription_id));
505                    }
506                }
507                Ok(dora_message::coordinator_to_cli::ControlRequestReply::Error(e)) => {
508                    let _ = ack_tx.send(Err(eyre!("{e}")));
509                }
510                _ => {
511                    let _ = ack_tx.send(Err(eyre!("unexpected topic subscribe reply")));
512                }
513            }
514        } else {
515            let _ = ack_tx.send(Err(eyre!("empty topic subscribe reply")));
516        }
517        return;
518    }
519
520    // Normal request-reply
521    if let Some(reply_tx) = pending_requests.remove(&id) {
522        let reply = if let Some(error) = error {
523            // Map WS error to ControlRequestReply::Error for compatibility
524            let err_reply = dora_message::coordinator_to_cli::ControlRequestReply::Error(error);
525            Ok(serde_json::to_vec(&err_reply).unwrap_or_default())
526        } else if let Some(raw) = result {
527            // Hand the raw JSON bytes to the caller, which parses them into the
528            // concrete reply type.
529            Ok(raw.get().as_bytes().to_vec())
530        } else {
531            Err(eyre!("empty WS response"))
532        };
533        let _ = reply_tx.send(reply);
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use serde_json::json;
541    use serde_json::value::RawValue;
542
543    fn raw(val: serde_json::Value) -> Box<RawValue> {
544        serde_json::value::to_raw_value(&val).unwrap()
545    }
546
547    #[test]
548    fn handle_response_routes_to_pending() {
549        let id = Uuid::new_v4();
550        let (tx, rx) = oneshot::channel();
551        let mut pending = HashMap::new();
552        pending.insert(id, tx);
553        let mut subscribes = HashMap::new();
554        let mut subs = Vec::new();
555        let mut topic_pending = HashMap::new();
556        let mut topic_subs = HashMap::new();
557
558        handle_response(
559            id,
560            Some(raw(json!({"List": []}))),
561            None,
562            &mut pending,
563            &mut subscribes,
564            &mut subs,
565            &mut topic_pending,
566            &mut topic_subs,
567        );
568
569        let mut rx = rx;
570        let result = rx.try_recv().unwrap().unwrap();
571        let val: serde_json::Value = serde_json::from_slice(&result).unwrap();
572        assert_eq!(val, json!({"List": []}));
573    }
574
575    #[test]
576    fn handle_response_orphan_response() {
577        let id = Uuid::new_v4();
578        let mut pending = HashMap::new();
579        let mut subscribes = HashMap::new();
580        let mut subs = Vec::new();
581        let mut topic_pending = HashMap::new();
582        let mut topic_subs = HashMap::new();
583
584        // Response with unknown id should be dropped without panic
585        handle_response(
586            id,
587            Some(raw(json!("ignored"))),
588            None,
589            &mut pending,
590            &mut subscribes,
591            &mut subs,
592            &mut topic_pending,
593            &mut topic_subs,
594        );
595    }
596
597    #[test]
598    fn handle_response_routes_event_to_subscriber() {
599        let id = Uuid::new_v4();
600        let (ack_tx, mut ack_rx) = oneshot::channel();
601        let (log_tx, log_rx) = std_mpsc::channel();
602        let mut pending = HashMap::new();
603        let mut subscribes = HashMap::new();
604        subscribes.insert(id, (ack_tx, log_tx));
605        let mut subs = Vec::new();
606        let mut topic_pending = HashMap::new();
607        let mut topic_subs = HashMap::new();
608
609        // Successful subscribe ack
610        handle_response(
611            id,
612            Some(raw(json!({"subscribed": true}))),
613            None,
614            &mut pending,
615            &mut subscribes,
616            &mut subs,
617            &mut topic_pending,
618            &mut topic_subs,
619        );
620
621        // ack should succeed
622        assert!(ack_rx.try_recv().unwrap().is_ok());
623        // log_tx should have been moved to log_subscribers
624        assert_eq!(subs.len(), 1);
625
626        // Verify the subscriber receives data by simulating what session_loop does
627        let payload = json!({"message": "test log"});
628        let bytes = serde_json::to_vec(&payload).unwrap();
629        subs[0].send(Ok(bytes.clone())).unwrap();
630        let received = log_rx.recv().unwrap().unwrap();
631        assert_eq!(received, bytes);
632    }
633
634    #[test]
635    fn handle_response_event_no_subscriber() {
636        // Simulate a subscribe error: ack gets error, no log_tx promoted
637        let id = Uuid::new_v4();
638        let (ack_tx, mut ack_rx) = oneshot::channel();
639        let (log_tx, _log_rx) = std_mpsc::channel();
640        let mut pending = HashMap::new();
641        let mut subscribes = HashMap::new();
642        subscribes.insert(id, (ack_tx, log_tx));
643        let mut subs = Vec::new();
644        let mut topic_pending = HashMap::new();
645        let mut topic_subs = HashMap::new();
646
647        handle_response(
648            id,
649            None,
650            Some("not found".into()),
651            &mut pending,
652            &mut subscribes,
653            &mut subs,
654            &mut topic_pending,
655            &mut topic_subs,
656        );
657
658        assert!(ack_rx.try_recv().unwrap().is_err());
659        assert!(subs.is_empty());
660    }
661
662    #[test]
663    fn handle_response_topic_subscribe_ack() {
664        let id = Uuid::new_v4();
665        let sub_id = Uuid::new_v4();
666        let (ack_tx, mut ack_rx) = oneshot::channel();
667        let (data_tx, _data_rx) = std_mpsc::channel();
668        let mut pending = HashMap::new();
669        let mut subscribes = HashMap::new();
670        let mut subs = Vec::new();
671        let mut topic_pending = HashMap::new();
672        topic_pending.insert(id, (ack_tx, data_tx));
673        let mut topic_subs = HashMap::new();
674
675        handle_response(
676            id,
677            Some(raw(json!({"TopicSubscribed": {
678                "subscription_id": sub_id,
679                "protocol_version": dora_message::TOPIC_DATA_PROTOCOL_VERSION,
680            }}))),
681            None,
682            &mut pending,
683            &mut subscribes,
684            &mut subs,
685            &mut topic_pending,
686            &mut topic_subs,
687        );
688
689        let result_id = ack_rx.try_recv().unwrap().unwrap();
690        assert_eq!(result_id, sub_id);
691        assert!(topic_subs.contains_key(&sub_id));
692    }
693
694    /// Binary topic frames are positionally encoded, so a coordinator on a
695    /// different encoding produces plausible garbage rather than a decode
696    /// error. The ack must be refused, and the subscription must not be
697    /// registered — otherwise frames would be dispatched to a consumer that
698    /// cannot read them (dora-rs/dora#3153).
699    fn ack_with_protocol_version(version: Option<u16>) -> (eyre::Result<Uuid>, bool) {
700        let id = Uuid::new_v4();
701        let sub_id = Uuid::new_v4();
702        let (ack_tx, mut ack_rx) = oneshot::channel();
703        let (data_tx, _data_rx) = std_mpsc::channel();
704        let mut pending = HashMap::new();
705        let mut subscribes = HashMap::new();
706        let mut subs = Vec::new();
707        let mut topic_pending = HashMap::new();
708        topic_pending.insert(id, (ack_tx, data_tx));
709        let mut topic_subs = HashMap::new();
710
711        let result = match version {
712            Some(version) => json!({"TopicSubscribed": {
713                "subscription_id": sub_id, "protocol_version": version,
714            }}),
715            // A pre-handshake coordinator omits the field entirely.
716            None => json!({"TopicSubscribed": {"subscription_id": sub_id}}),
717        };
718
719        handle_response(
720            id,
721            Some(raw(result)),
722            None,
723            &mut pending,
724            &mut subscribes,
725            &mut subs,
726            &mut topic_pending,
727            &mut topic_subs,
728        );
729
730        (ack_rx.try_recv().unwrap(), topic_subs.contains_key(&sub_id))
731    }
732
733    #[test]
734    fn topic_subscribe_ack_rejects_older_protocol_version() {
735        let (ack, registered) = ack_with_protocol_version(Some(1));
736        let err = ack.expect_err("an older protocol version must be rejected");
737        let msg = format!("{err}");
738        assert!(
739            msg.contains("version 1") && msg.contains("misparse"),
740            "error should name the peer's version and why it is fatal, got: {msg}"
741        );
742        assert!(
743            !registered,
744            "a rejected subscription must not be registered for frame dispatch"
745        );
746    }
747
748    #[test]
749    fn topic_subscribe_ack_rejects_missing_protocol_version() {
750        let (ack, registered) = ack_with_protocol_version(None);
751        let err = ack.expect_err("a pre-handshake coordinator must be rejected");
752        assert!(
753            format!("{err}").contains("predates"),
754            "error should say the coordinator predates the handshake, got: {err}"
755        );
756        assert!(
757            !registered,
758            "a rejected subscription must not be registered for frame dispatch"
759        );
760    }
761
762    #[tokio::test]
763    async fn connect_rejects_from_async_context() {
764        let addr: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap();
765        match WsSession::connect(addr) {
766            Err(err) => assert!(
767                format!("{err}").contains("async context"),
768                "expected 'async context' in error, got: {err}"
769            ),
770            Ok(_) => panic!("expected error from async context"),
771        }
772    }
773
774    #[tokio::test]
775    async fn sender_drop_signals_receiver_error() {
776        // Verify that dropping the oneshot sender (simulating session close)
777        // causes the receiver to get a RecvError.
778        let (tx, rx) = oneshot::channel::<eyre::Result<Vec<u8>>>();
779        drop(tx);
780        let result = rx.await;
781        assert!(result.is_err()); // RecvError = sender dropped
782    }
783}