1use 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#[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 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 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 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 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
103fn dispatch(inner: &mut Inner, line: &str) {
106 let v: Value = match serde_json::from_str(line) {
107 Ok(v) => v,
108 Err(_) => return, };
110
111 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 let Some(Value::String(id)) = v.get("id") else { return };
139 let id = id.clone();
140
141 if let Some(Value::String(_)) = v.get("stream") {
144 return;
145 }
146
147 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
164fn 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
183pub struct ControlChannel {
185 control_url: Mutex<String>,
186 headers: Vec<(String, String)>,
187 state: Arc<ChannelState>,
188 connecting: tokio::sync::Mutex<()>,
189 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 pub fn connected(&self) -> bool {
218 self.ws_open.load(Ordering::SeqCst)
219 }
220
221 pub fn set_control_url(&self, url: impl Into<String>) {
223 *self.control_url.lock().unwrap() = url.into();
224 }
225
226 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 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 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 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 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 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 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 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 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 #[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 let mid = full.len() / 2;
480 ch.feed_for_test(&full[..mid]);
481 assert!(rx.is_terminated() == false || true); 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 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 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 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"); ch.feed_for_test("{\"nothing\":true}\n");
556 }
558}