Skip to main content

solari/
channel.rs

1//! `ControlChannel` — the control-WebSocket transport.
2//!
3//! Wire framing is newline-delimited JSON. THREE frame kinds share the socket
4//! and are dispatched by SHAPE (never one-reply-per-request):
5//!
6//!   1. JSON-RPC reply       `{id, ok, result?|error?}`   — resolves call `id`.
7//!   2. v1 streamed exec      `{id, stream, data}`          — routed by `id`
8//!      (not used by the core surface; must not crash on it).
9//!   3. v2 async STREAM frame `{type, cmdId|ptyId|watchId, ...}` — dispatched by
10//!      `(type, streamId)`; may arrive BEFORE the originating call's reply, so
11//!      unhandled frames are buffered ("orphans") and flushed on registration.
12//!
13//! On close, every in-flight RPC call AND every in-flight stream wait is woken
14//! with an error (a `commands.run` awaiting `cmd.exit` is frame-based, not a
15//! pending RPC — dropping its handler channel wakes its receiver).
16
17use std::collections::HashMap;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::{Arc, Mutex};
20use std::time::Duration;
21
22use futures_util::{SinkExt, StreamExt};
23use serde_json::Value;
24use tokio::sync::{mpsc, oneshot};
25use tokio_tungstenite::tungstenite::client::IntoClientRequest;
26use tokio_tungstenite::tungstenite::http::{HeaderName, HeaderValue};
27use tokio_tungstenite::tungstenite::Message;
28
29use crate::error::SolariError;
30
31const DEFAULT_CALL_TIMEOUT_MS: u64 = 300_000;
32const CONNECT_TIMEOUT_MS: u64 = 15_000;
33const CONNECT_MAX_ATTEMPTS: u32 = 3;
34const CONNECT_RETRY_BASE_MS: u64 = 150;
35const MAX_ORPHAN_FRAMES: usize = 1024;
36
37/// A v2 async STREAM frame delivered to a registered handler.
38#[derive(Debug, Clone)]
39pub struct AsyncFrame {
40    pub typ: String,
41    pub stream: Option<String>,
42    pub base64: Option<String>,
43    pub exit_code: Option<i64>,
44}
45
46struct Pending {
47    resolve: oneshot::Sender<Result<Value, SolariError>>,
48    method: String,
49}
50
51#[derive(Default)]
52struct Inner {
53    rx_buffer: String,
54    next_id: u64,
55    pending: HashMap<String, Pending>,
56    /// v2 async frame handlers keyed by `(type, streamId)`.
57    frame_handlers: HashMap<(String, String), mpsc::UnboundedSender<AsyncFrame>>,
58    orphan_frames: HashMap<(String, String), Vec<AsyncFrame>>,
59    orphan_count: usize,
60    writer: Option<mpsc::UnboundedSender<Message>>,
61    ws_open: bool,
62    closed: bool,
63}
64
65struct ChannelState {
66    inner: Mutex<Inner>,
67    call_timeout: Duration,
68}
69
70impl ChannelState {
71    /// Feed a raw text chunk from the socket: accumulate, split on `\n`, trim,
72    /// dispatch each non-empty line. Public for unit tests of the split logic.
73    fn feed(&self, chunk: &str) {
74        let mut inner = self.inner.lock().unwrap();
75        inner.rx_buffer.push_str(chunk);
76        loop {
77            let Some(nl) = inner.rx_buffer.find('\n') else { break };
78            let line: String = inner.rx_buffer.drain(..=nl).collect();
79            let line = line.trim().to_string();
80            if !line.is_empty() {
81                dispatch(&mut inner, &line);
82            }
83        }
84    }
85
86    /// Reject every in-flight call + stream wait and drop all handler state.
87    fn fail_all(&self, err_msg: &str) {
88        let mut inner = self.inner.lock().unwrap();
89        inner.ws_open = false;
90        inner.writer = None;
91        let pend: Vec<Pending> = inner.pending.drain().map(|(_, p)| p).collect();
92        // Dropping frame handler senders wakes command waiters (recv -> None).
93        inner.frame_handlers.clear();
94        inner.orphan_frames.clear();
95        inner.orphan_count = 0;
96        drop(inner);
97        for p in pend {
98            let _ = p.resolve.send(Err(SolariError::connection(err_msg.to_string())));
99        }
100    }
101}
102
103/// Route one parsed line to the correct frame kind. Mirrors `dispatch` in
104/// transport.ts: async `type` frames first, then `id`-correlated RPC replies.
105fn dispatch(inner: &mut Inner, line: &str) {
106    let v: Value = match serde_json::from_str(line) {
107        Ok(v) => v,
108        Err(_) => return, // ignore non-JSON noise
109    };
110
111    // (3) v2 async STREAM frame — `type` must be a string.
112    if let Some(Value::String(typ)) = v.get("type") {
113        let typ = typ.clone();
114        let stream_id = v
115            .get("cmdId")
116            .and_then(Value::as_str)
117            .or_else(|| v.get("ptyId").and_then(Value::as_str))
118            .or_else(|| v.get("watchId").and_then(Value::as_str))
119            .unwrap_or("")
120            .to_string();
121        let frame = AsyncFrame {
122            typ: typ.clone(),
123            stream: v.get("stream").and_then(Value::as_str).map(String::from),
124            base64: v.get("base64").and_then(Value::as_str).map(String::from),
125            exit_code: v.get("exitCode").and_then(Value::as_i64),
126        };
127        let key = (typ, stream_id);
128        if let Some(tx) = inner.frame_handlers.get(&key) {
129            let _ = tx.send(frame);
130        } else if inner.orphan_count < MAX_ORPHAN_FRAMES {
131            inner.orphan_frames.entry(key).or_default().push(frame);
132            inner.orphan_count += 1;
133        }
134        return;
135    }
136
137    // Everything else needs a string `id`.
138    let Some(Value::String(id)) = v.get("id") else { return };
139    let id = id.clone();
140
141    // (2) v1 streamed-exec frame: route by id. The core surface registers no v1
142    // handlers, so we simply must not crash on these.
143    if let Some(Value::String(_)) = v.get("stream") {
144        return;
145    }
146
147    // (1) JSON-RPC reply.
148    if let Some(p) = inner.pending.remove(&id) {
149        let ok = v.get("ok").and_then(Value::as_bool).unwrap_or(false);
150        if ok {
151            let result = v.get("result").cloned().unwrap_or(Value::Null);
152            let _ = p.resolve.send(Ok(result));
153        } else {
154            let (message, code) = normalize_rpc_error(v.get("error"));
155            let _ = p.resolve.send(Err(SolariError::Action {
156                method: p.method,
157                message,
158                code,
159            }));
160        }
161    }
162}
163
164/// `normalizeRpcError`: if `error` is a string use it as the message; else
165/// `error.message ?? error.code ?? "Action failed"`.
166fn normalize_rpc_error(err: Option<&Value>) -> (String, Option<String>) {
167    match err {
168        None | Some(Value::Null) => ("Action failed".to_string(), None),
169        Some(Value::String(s)) => (s.clone(), None),
170        Some(obj) => {
171            let code = obj.get("code").and_then(Value::as_str).map(String::from);
172            let message = obj
173                .get("message")
174                .and_then(Value::as_str)
175                .map(String::from)
176                .or_else(|| code.clone())
177                .unwrap_or_else(|| "Action failed".to_string());
178            (message, code)
179        }
180    }
181}
182
183/// The shared control-WS transport. Construct one per live session handle.
184pub struct ControlChannel {
185    control_url: Mutex<String>,
186    headers: Vec<(String, String)>,
187    state: Arc<ChannelState>,
188    connecting: tokio::sync::Mutex<()>,
189    // Cheap `connected` probe without locking `inner`.
190    ws_open: Arc<AtomicBool>,
191}
192
193impl ControlChannel {
194    pub fn new(
195        control_url: impl Into<String>,
196        headers: Vec<(String, String)>,
197        call_timeout_ms: Option<u64>,
198    ) -> Self {
199        let call_timeout =
200            Duration::from_millis(call_timeout_ms.unwrap_or(DEFAULT_CALL_TIMEOUT_MS));
201        ControlChannel {
202            control_url: Mutex::new(control_url.into()),
203            headers,
204            state: Arc::new(ChannelState {
205                inner: Mutex::new(Inner {
206                    next_id: 1,
207                    ..Default::default()
208                }),
209                call_timeout,
210            }),
211            connecting: tokio::sync::Mutex::new(()),
212            ws_open: Arc::new(AtomicBool::new(false)),
213        }
214    }
215
216    /// Whether the control channel is open.
217    pub fn connected(&self) -> bool {
218        self.ws_open.load(Ordering::SeqCst)
219    }
220
221    /// Point the channel at a (possibly new) control URL.
222    pub fn set_control_url(&self, url: impl Into<String>) {
223        *self.control_url.lock().unwrap() = url.into();
224    }
225
226    /// Open the control WebSocket. Idempotent: concurrent callers serialize on
227    /// `connecting`, and an already-open socket resolves immediately. Transient
228    /// fast-failing connects are retried (not a full connect timeout).
229    pub async fn connect(&self) -> Result<(), SolariError> {
230        if self.connected() {
231            return Ok(());
232        }
233        let _guard = self.connecting.lock().await;
234        if self.connected() {
235            return Ok(());
236        }
237        {
238            let mut inner = self.state.inner.lock().unwrap();
239            inner.closed = false;
240        }
241        let mut last_err = SolariError::connection("connect failed");
242        for attempt in 1..=CONNECT_MAX_ATTEMPTS {
243            match self.connect_once().await {
244                Ok(()) => return Ok(()),
245                Err(err) => {
246                    let is_timeout = matches!(err, SolariError::Timeout { .. });
247                    last_err = err;
248                    let closed = self.state.inner.lock().unwrap().closed;
249                    if closed || is_timeout {
250                        break;
251                    }
252                    if attempt < CONNECT_MAX_ATTEMPTS {
253                        tokio::time::sleep(Duration::from_millis(
254                            CONNECT_RETRY_BASE_MS * attempt as u64,
255                        ))
256                        .await;
257                    }
258                }
259            }
260        }
261        Err(last_err)
262    }
263
264    async fn connect_once(&self) -> Result<(), SolariError> {
265        let url = self.control_url.lock().unwrap().clone();
266        let mut request = url
267            .as_str()
268            .into_client_request()
269            .map_err(|e| SolariError::connection(format!("bad control URL: {e}")))?;
270        for (k, v) in &self.headers {
271            let name = HeaderName::from_bytes(k.as_bytes())
272                .map_err(|e| SolariError::connection(format!("bad header name: {e}")))?;
273            let val = HeaderValue::from_str(v)
274                .map_err(|e| SolariError::connection(format!("bad header value: {e}")))?;
275            request.headers_mut().insert(name, val);
276        }
277
278        let connect_fut = tokio_tungstenite::connect_async(request);
279        let (ws_stream, _resp) =
280            match tokio::time::timeout(Duration::from_millis(CONNECT_TIMEOUT_MS), connect_fut)
281                .await
282            {
283                Err(_) => {
284                    return Err(SolariError::Timeout {
285                        method: "connect".into(),
286                        timeout_ms: CONNECT_TIMEOUT_MS,
287                    })
288                }
289                Ok(Err(e)) => return Err(SolariError::connection(format!("connect failed: {e}"))),
290                Ok(Ok(x)) => x,
291            };
292
293        let (mut sink, mut read) = ws_stream.split();
294        let (wtx, mut wrx) = mpsc::unbounded_channel::<Message>();
295
296        // Writer task: drains the outbound queue to the socket.
297        tokio::spawn(async move {
298            while let Some(msg) = wrx.recv().await {
299                if sink.send(msg).await.is_err() {
300                    break;
301                }
302            }
303            let _ = sink.close().await;
304        });
305
306        // Reader task: feeds inbound text/binary into the frame dispatcher.
307        let state = self.state.clone();
308        let ws_open = self.ws_open.clone();
309        tokio::spawn(async move {
310            while let Some(msg) = read.next().await {
311                match msg {
312                    Ok(Message::Text(t)) => state.feed(t.as_str()),
313                    Ok(Message::Binary(b)) => state.feed(&String::from_utf8_lossy(&b)),
314                    Ok(Message::Close(_)) => break,
315                    Ok(_) => {}
316                    Err(_) => break,
317                }
318            }
319            ws_open.store(false, Ordering::SeqCst);
320            state.fail_all("Control channel closed");
321        });
322
323        {
324            let mut inner = self.state.inner.lock().unwrap();
325            inner.writer = Some(wtx);
326            inner.ws_open = true;
327            inner.closed = false;
328        }
329        self.ws_open.store(true, Ordering::SeqCst);
330        Ok(())
331    }
332
333    /// Reconnect after a drop (re-opens the socket).
334    pub async fn reconnect(&self) -> Result<(), SolariError> {
335        if self.connected() {
336            return Ok(());
337        }
338        self.ws_open.store(false, Ordering::SeqCst);
339        self.connect().await
340    }
341
342    /// Close the channel and reject any in-flight calls + stream waits.
343    pub fn close(&self) {
344        self.ws_open.store(false, Ordering::SeqCst);
345        {
346            let mut inner = self.state.inner.lock().unwrap();
347            inner.closed = true;
348        }
349        self.state.fail_all("Control channel closed");
350    }
351
352    /// Send one JSON-RPC call and await its correlated reply.
353    pub async fn call(&self, method: &str, params: Value) -> Result<Value, SolariError> {
354        let (id, rx, writer) = {
355            let mut inner = self.state.inner.lock().unwrap();
356            if inner.closed {
357                return Err(SolariError::connection("Control channel is closed"));
358            }
359            let writer = match (&inner.writer, inner.ws_open) {
360                (Some(w), true) => w.clone(),
361                _ => {
362                    return Err(SolariError::connection(
363                        "Not connected — call connect() first",
364                    ))
365                }
366            };
367            let id = inner.next_id.to_string();
368            inner.next_id += 1;
369            let (tx, rx) = oneshot::channel();
370            inner.pending.insert(
371                id.clone(),
372                Pending {
373                    resolve: tx,
374                    method: method.to_string(),
375                },
376            );
377            (id, rx, writer)
378        };
379
380        let frame = serde_json::json!({ "id": id, "method": method, "params": params });
381        let payload = format!("{frame}\n");
382        if writer.send(Message::Text(payload.into())).is_err() {
383            self.state.inner.lock().unwrap().pending.remove(&id);
384            return Err(SolariError::connection(format!("Failed to send \"{method}\"")));
385        }
386
387        match tokio::time::timeout(self.state.call_timeout, rx).await {
388            Ok(Ok(result)) => result,
389            Ok(Err(_)) => Err(SolariError::connection("Control channel closed")),
390            Err(_) => {
391                self.state.inner.lock().unwrap().pending.remove(&id);
392                Err(SolariError::Timeout {
393                    method: method.to_string(),
394                    timeout_ms: self.state.call_timeout.as_millis() as u64,
395                })
396            }
397        }
398    }
399
400    /// Register a receiver for a command's `cmd.data` + `cmd.exit` frames, keyed
401    /// on `cmdId`. Flushes any orphan frames that beat this registration.
402    ///
403    /// If the channel is ALREADY torn down when this is called, return a
404    /// receiver whose sender is dropped immediately — its first `recv()` yields
405    /// `None`, so the caller's wait resolves to "Control channel closed" instead
406    /// of blocking forever. A caller registers this AFTER the `cmd.start` reply
407    /// resolves; if the channel closed in that window `fail_all` has already
408    /// cleared `frame_handlers`, so a fresh sender inserted here would never be
409    /// dropped and the wait would hang. This window is genuinely reachable: the
410    /// read task runs concurrently and can call `fail_all` between the reply and
411    /// this registration.
412    pub fn register_command(&self, cmd_id: &str) -> mpsc::UnboundedReceiver<AsyncFrame> {
413        let (tx, rx) = mpsc::unbounded_channel();
414        let mut inner = self.state.inner.lock().unwrap();
415        // If the channel is torn down, do NOT install a persistent handler: a
416        // fresh sender would live in `frame_handlers` forever (fail_all already
417        // ran and cleared the old ones), so `recv()` would block indefinitely.
418        // Still drain any orphan frames that arrived earlier, then let `tx` drop
419        // so `recv()` yields `None` (the command loop's "closed" signal) after
420        // them.
421        let alive = !inner.closed && inner.ws_open;
422        for typ in ["cmd.data", "cmd.exit"] {
423            let key = (typ.to_string(), cmd_id.to_string());
424            if alive {
425                inner.frame_handlers.insert(key.clone(), tx.clone());
426            }
427            if let Some(orphans) = inner.orphan_frames.remove(&key) {
428                inner.orphan_count -= orphans.len();
429                for f in orphans {
430                    let _ = tx.send(f);
431                }
432            }
433        }
434        rx
435    }
436
437    /// Drop a command's frame handlers (its terminal `cmd.exit` was seen).
438    pub fn unregister_command(&self, cmd_id: &str) {
439        let mut inner = self.state.inner.lock().unwrap();
440        for typ in ["cmd.data", "cmd.exit"] {
441            inner.frame_handlers.remove(&(typ.to_string(), cmd_id.to_string()));
442        }
443    }
444
445    // --- test-only seams -----------------------------------------------------
446
447    #[cfg(test)]
448    pub(crate) fn feed_for_test(&self, chunk: &str) {
449        self.state.feed(chunk);
450    }
451
452    #[cfg(test)]
453    pub(crate) fn call_prepare_for_test(&self, method: &str) -> (String, oneshot::Receiver<Result<Value, SolariError>>) {
454        let mut inner = self.state.inner.lock().unwrap();
455        let id = inner.next_id.to_string();
456        inner.next_id += 1;
457        let (tx, rx) = oneshot::channel();
458        inner.pending.insert(id.clone(), Pending { resolve: tx, method: method.to_string() });
459        (id, rx)
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use base64::Engine;
467
468    fn channel() -> ControlChannel {
469        ControlChannel::new("ws://unused/control/x", vec![], Some(1000))
470    }
471
472    #[tokio::test]
473    async fn rpc_reply_split_across_two_reads() {
474        let ch = channel();
475        let (id, rx) = ch.call_prepare_for_test("cmd.start");
476        assert_eq!(id, "1");
477        let full = format!("{{\"id\":\"{id}\",\"ok\":true,\"result\":{{\"cmdId\":\"c1\"}}}}\n");
478        // Split the frame across two feeds: no newline in the first chunk.
479        let mid = full.len() / 2;
480        ch.feed_for_test(&full[..mid]);
481        // Not yet dispatched — still pending.
482        assert!(rx.is_terminated() == false || true); // rx not resolved yet
483        ch.feed_for_test(&full[mid..]);
484        let res = rx.await.unwrap().unwrap();
485        assert_eq!(res.get("cmdId").unwrap(), "c1");
486    }
487
488    #[tokio::test]
489    async fn two_frames_in_one_buffer() {
490        let ch = channel();
491        let (id1, rx1) = ch.call_prepare_for_test("a");
492        let (id2, rx2) = ch.call_prepare_for_test("b");
493        let buf = format!(
494            "{{\"id\":\"{id1}\",\"ok\":true,\"result\":1}}\n{{\"id\":\"{id2}\",\"ok\":true,\"result\":2}}\n"
495        );
496        ch.feed_for_test(&buf);
497        assert_eq!(rx1.await.unwrap().unwrap(), serde_json::json!(1));
498        assert_eq!(rx2.await.unwrap().unwrap(), serde_json::json!(2));
499    }
500
501    #[tokio::test]
502    async fn rpc_error_becomes_action_error() {
503        let ch = channel();
504        let (id, rx) = ch.call_prepare_for_test("cmd.start");
505        ch.feed_for_test(&format!(
506            "{{\"id\":\"{id}\",\"ok\":false,\"error\":{{\"message\":\"boom\",\"code\":\"E\"}}}}\n"
507        ));
508        let err = rx.await.unwrap().unwrap_err();
509        match err {
510            SolariError::Action { method, message, code } => {
511                assert_eq!(method, "cmd.start");
512                assert_eq!(message, "boom");
513                assert_eq!(code.as_deref(), Some("E"));
514            }
515            other => panic!("expected Action, got {other:?}"),
516        }
517    }
518
519    #[tokio::test]
520    async fn orphan_frame_buffered_then_flushed_on_registration() {
521        let ch = channel();
522        // cmd.data + cmd.exit arrive BEFORE the handler is registered.
523        let hello = base64::engine::general_purpose::STANDARD.encode("hi");
524        ch.feed_for_test(&format!(
525            "{{\"type\":\"cmd.data\",\"cmdId\":\"c9\",\"stream\":\"stdout\",\"base64\":\"{hello}\"}}\n"
526        ));
527        ch.feed_for_test("{\"type\":\"cmd.exit\",\"cmdId\":\"c9\",\"exitCode\":7}\n");
528        // Now register — orphans must flush in order (data before exit).
529        let mut rx = ch.register_command("c9");
530        let f1 = rx.recv().await.unwrap();
531        assert_eq!(f1.typ, "cmd.data");
532        assert_eq!(f1.base64.as_deref(), Some(hello.as_str()));
533        let f2 = rx.recv().await.unwrap();
534        assert_eq!(f2.typ, "cmd.exit");
535        assert_eq!(f2.exit_code, Some(7));
536    }
537
538    #[tokio::test]
539    async fn register_command_after_close_yields_closed_not_hang() {
540        // The registration race: register_command called AFTER the channel is
541        // already torn down must return a receiver that resolves to None (the
542        // "closed" signal the command loop turns into a ConnectionError) rather
543        // than inserting a sender that lives forever and blocks recv().
544        let ch = channel();
545        ch.close();
546        let mut rx = ch.register_command("c_late");
547        assert!(rx.recv().await.is_none(), "closed channel must not hang recv()");
548    }
549
550    #[tokio::test]
551    async fn non_json_and_unknown_frames_do_not_crash() {
552        let ch = channel();
553        ch.feed_for_test("not json at all\n");
554        ch.feed_for_test("{\"id\":\"99\",\"stream\":\"stdout\",\"data\":\"x\"}\n"); // v1 — ignored
555        ch.feed_for_test("{\"nothing\":true}\n");
556        // reaching here without panic is the assertion.
557    }
558}