1use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13
14use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
15use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
16use tokio::net::UnixStream;
17use tokio::sync::mpsc;
18
19use super::handle::CMD_CHAN_CAP;
20use super::transport::{ClientTransport, TransportError, ATTACH_TIMEOUT, ATTACH_TIMEOUT_PARKED};
21use super::types::*;
22use super::view::RuntimeView;
23use super::wire::*;
24
25pub const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
26pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2);
27
28pub struct Connected {
30 reader: BufReader<OwnedReadHalf>,
31 writer: mpsc::Sender<ClientFrame>,
32 writer_task: tokio::task::JoinHandle<()>,
33 pub welcome: Welcome,
34 path: PathBuf,
36 hello: Hello,
37 buf: FrameBuf,
38}
39
40pub struct Pong {
41 pub pid: u32,
42 pub uptime_s: u64,
43 pub sessions: usize,
44}
45
46#[derive(Default)]
50pub struct FrameBuf {
51 line: Vec<u8>,
52 last_frame_bytes: usize,
54}
55
56const FRAME_BUF_KEEP: usize = 64 * 1024;
57const FRAME_BUF_SHRINK_ABOVE: usize = 1024 * 1024;
58
59impl FrameBuf {
60 fn after_frame(&mut self, n: usize) {
61 self.last_frame_bytes = n;
62 if self.line.capacity() > FRAME_BUF_SHRINK_ABOVE {
63 self.line = Vec::new();
64 self.line.reserve(FRAME_BUF_KEEP);
65 }
66 }
67}
68
69async fn read_frame(reader: &mut BufReader<OwnedReadHalf>, buf: &mut FrameBuf) -> Result<Option<DaemonFrame>, TransportError> {
70 loop {
71 buf.line.clear();
72 let n = tokio::io::AsyncReadExt::take(&mut *reader, MAX_FRAME_BYTES as u64 + 1)
74 .read_until(b'\n', &mut buf.line)
75 .await?;
76 if n == 0 {
77 return Ok(None);
78 }
79 if n > MAX_FRAME_BYTES {
80 return Err(TransportError::Protocol(frame_limit_msg()));
81 }
82 let t = std::str::from_utf8(&buf.line)
83 .map_err(|e| TransportError::Protocol(format!("frame is not utf-8: {e}")))?
84 .trim_end();
85 if t.is_empty() {
86 continue;
87 }
88 let decoded = decode_line::<DaemonFrame>(t).map(Some).map_err(TransportError::Protocol);
89 let len = t.len();
90 buf.after_frame(len);
91 return decoded;
92 }
93}
94
95fn spawn_writer(mut w: OwnedWriteHalf) -> (mpsc::Sender<ClientFrame>, tokio::task::JoinHandle<()>) {
96 let (tx, mut rx) = mpsc::channel::<ClientFrame>(CMD_CHAN_CAP);
97 let task = tokio::spawn(async move {
98 while let Some(frame) = rx.recv().await {
99 let line = match encode_line(&frame) {
100 Ok(l) => l,
101 Err(e) => {
102 tracing::warn!(frame = ?frame, "socket_transport: encode failed: {e}");
103 continue;
104 }
105 };
106 if let Err(e) = w.write_all(line.as_bytes()).await {
107 tracing::debug!("socket_transport: write failed: {e}");
108 break;
109 }
110 if matches!(frame, ClientFrame::Bye) {
111 let _ = w.shutdown().await;
112 break;
113 }
114 }
115 });
116 (tx, task)
117}
118
119impl Connected {
120 pub async fn connect(path: &Path, hello: Hello) -> Result<Self, TransportError> {
123 let stream = tokio::time::timeout(CONNECT_TIMEOUT, UnixStream::connect(path))
124 .await
125 .map_err(|_| TransportError::Protocol("connect timed out".into()))??;
126 let (r, w) = stream.into_split();
127 let mut reader = BufReader::new(r);
128 let (writer, writer_task) = spawn_writer(w);
129 let client_version = hello.protocol_version;
130 let hello_copy = hello.clone();
131 let mut buf = FrameBuf::default();
132 writer
133 .send(ClientFrame::Hello(hello))
134 .await
135 .map_err(|_| TransportError::Closed)?;
136 let frame = tokio::time::timeout(HANDSHAKE_TIMEOUT, read_frame(&mut reader, &mut buf))
137 .await
138 .map_err(|_| TransportError::Protocol("handshake timed out".into()))??;
139 match frame {
140 Some(DaemonFrame::Welcome(welcome)) => {
141 if welcome.protocol_version != client_version {
142 return Err(TransportError::Version { client: client_version, daemon: welcome.protocol_version });
143 }
144 Ok(Self { reader, writer, writer_task, welcome, path: path.to_path_buf(), hello: hello_copy, buf })
145 }
146 Some(DaemonFrame::Refused { reason: RefuseReason::Version { daemon_version, .. }, .. }) => {
147 Err(TransportError::Version { client: client_version, daemon: daemon_version })
148 }
149 Some(DaemonFrame::Refused { reason, message }) => {
150 Err(TransportError::Protocol(format!("refused ({reason:?}): {message}")))
151 }
152 Some(other) => Err(TransportError::Protocol(format!("expected welcome, got {other:?}"))),
153 None => Err(TransportError::Closed),
154 }
155 }
156
157 async fn request(&mut self, frame: ClientFrame) -> Result<DaemonFrame, TransportError> {
158 self.writer.send(frame).await.map_err(|_| TransportError::Closed)?;
159 tokio::time::timeout(HANDSHAKE_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
160 .await
161 .map_err(|_| TransportError::Protocol("reply timed out".into()))??
162 .ok_or(TransportError::Closed)
163 }
164
165 pub async fn ping(&mut self) -> Result<Pong, TransportError> {
166 match self.request(ClientFrame::Ping).await? {
167 DaemonFrame::Pong { pid, uptime_s, sessions } => Ok(Pong { pid, uptime_s, sessions }),
168 other => Err(TransportError::Protocol(format!("expected pong, got {other:?}"))),
169 }
170 }
171
172 pub async fn sessions(&mut self) -> Result<Vec<SessionMeta>, TransportError> {
173 match self.request(ClientFrame::Sessions).await? {
174 DaemonFrame::SessionList { sessions } => Ok(sessions),
175 other => Err(TransportError::Protocol(format!("expected session list, got {other:?}"))),
176 }
177 }
178
179 pub async fn purge(&mut self) -> Result<Pong, TransportError> {
181 match self.request(ClientFrame::Purge).await? {
182 DaemonFrame::Pong { pid, uptime_s, sessions } => Ok(Pong { pid, uptime_s, sessions }),
183 DaemonFrame::Error { message, .. } => Err(TransportError::Protocol(message)),
184 other => Err(TransportError::Protocol(format!("expected pong, got {other:?}"))),
185 }
186 }
187
188 pub async fn shutdown(&mut self, force: bool) -> Result<(), TransportError> {
189 match self.request(ClientFrame::Shutdown { force }).await? {
190 DaemonFrame::Bye { .. } => Ok(()),
191 DaemonFrame::Error { message, .. } => Err(TransportError::Protocol(message)),
192 other => Err(TransportError::Protocol(format!("expected bye, got {other:?}"))),
193 }
194 }
195
196 pub async fn bye(self) {
198 let _ = self.writer.send(ClientFrame::Bye).await;
199 let _ = tokio::time::timeout(Duration::from_millis(200), self.writer_task).await;
200 }
201}
202
203pub struct SocketTransport {
204 reader: BufReader<OwnedReadHalf>,
205 writer: mpsc::Sender<ClientFrame>,
206 _writer_task: tokio::task::JoinHandle<()>,
207 session: SessionId,
208 meta: SessionMeta,
209 client: ClientId,
210 view: Arc<arc_swap::ArcSwap<RuntimeView>>,
211 pub welcome: Welcome,
212 mode: AttachMode,
213 input_owner: Option<ClientId>,
214 ended: bool,
215 path: PathBuf,
216 hello: Hello,
217 reload_pending: Option<u64>,
219 last_error: Option<TransportError>,
222 messages: Vec<crate::SharedMessage>,
227 pending_digest: Option<ConversationDigest>,
229 history: HistoryMode,
231 buf: FrameBuf,
232}
233
234impl SocketTransport {
235 pub async fn connect(path: &Path, hello: Hello) -> Result<Connected, TransportError> {
237 Connected::connect(path, hello).await
238 }
239
240 pub async fn attach(mut conn: Connected, attach: Attach) -> Result<(Self, AttachSnapshot), TransportError> {
244 let (mode, parked) = match &attach {
245 Attach::Existing { session_id, mode } => (
246 *mode,
247 conn.welcome
248 .sessions
249 .iter()
250 .any(|m| &m.id == session_id && m.lifecycle == SessionLifecycle::Parked),
251 ),
252 Attach::Create { mode, .. } => (*mode, false),
253 };
254 conn.writer
255 .send(ClientFrame::Attach(attach))
256 .await
257 .map_err(|_| TransportError::Closed)?;
258 let budget = if parked { ATTACH_TIMEOUT_PARKED } else { ATTACH_TIMEOUT };
259 let deadline = tokio::time::Instant::now() + budget;
260 let attached = loop {
261 let frame = tokio::time::timeout_at(deadline, read_frame(&mut conn.reader, &mut conn.buf))
262 .await
263 .map_err(|_| TransportError::Protocol("attach timed out".into()))??;
264 match frame {
265 Some(DaemonFrame::Attached(a)) => break a,
266 Some(DaemonFrame::Error { message, .. }) => return Err(TransportError::Refused(message)),
267 Some(DaemonFrame::Event(w)) => match w.event {
268 WireSessionEvent::AttachRefused { message } => return Err(TransportError::Refused(message)),
269 _ => continue,
271 },
272 Some(DaemonFrame::Bye { .. }) | None => return Err(TransportError::Closed),
273 Some(other) => return Err(TransportError::Protocol(format!("expected attached, got {other:?}"))),
274 }
275 };
276 let (client, snapshot) = attached.into_snapshot();
277 let meta = snapshot.meta.clone();
278 let view = Arc::new(arc_swap::ArcSwap::from_pointee(snapshot.view.clone()));
279 let history = conn.hello.client.history;
280 let messages = match history {
281 HistoryMode::Full => snapshot.conversation.api_messages.clone(),
282 HistoryMode::Digest => Vec::new(),
283 };
284 Ok((
285 Self {
286 reader: conn.reader,
287 writer: conn.writer,
288 _writer_task: conn.writer_task,
289 session: meta.id.clone(),
290 meta,
291 client,
292 view,
293 welcome: conn.welcome,
294 mode,
295 input_owner: snapshot.input_owner,
296 ended: false,
297 path: conn.path,
298 hello: conn.hello,
299 reload_pending: None,
300 last_error: None,
301 messages,
302 pending_digest: None,
303 history,
304 buf: conn.buf,
305 },
306 snapshot,
307 ))
308 }
309
310 pub fn last_error(&self) -> Option<&TransportError> {
312 self.last_error.as_ref()
313 }
314
315 pub fn is_reload_pending(&self) -> bool {
318 self.reload_pending.is_some()
319 }
320
321 pub fn daemon_pid(&self) -> u32 {
323 self.welcome.pid
324 }
325
326 pub fn reconnect_budget() -> Duration {
328 std::env::var("SYNAPS_TUI_ATTACH_RECONNECT_SECS")
329 .ok()
330 .and_then(|s| s.parse().ok())
331 .map(Duration::from_secs)
332 .unwrap_or(Duration::from_secs(60))
333 }
334
335 pub async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
339 let was_owner = self.input_owner == Some(self.client);
340 let generation = self.reload_pending.unwrap_or(self.welcome.generation);
341 let mut hello = self.hello.clone();
342 hello.reconnect_of = Some(ClientReconnect {
343 previous_client: self.client,
344 session_id: self.session.clone(),
345 was_owner,
346 generation,
347 });
348 let mode = if was_owner { AttachMode::Takeover } else { mode };
349 match Connected::connect(&self.path, hello).await {
350 Ok(conn) => {
351 match Self::attach(conn, Attach::Existing { session_id: self.session.clone(), mode }).await {
352 Ok((t, snap)) => {
353 *self = t;
354 Ok(snap)
355 }
356 Err(e) => Err(e),
357 }
358 }
359 Err(e) => Err(e),
360 }
361 }
362
363 pub async fn ping(path: &Path) -> Result<Pong, TransportError> {
366 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
367 let p = c.ping().await;
368 c.bye().await;
369 p
370 }
371
372 pub async fn sessions(path: &Path) -> Result<Vec<SessionMeta>, TransportError> {
373 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
374 let s = c.sessions().await;
375 c.bye().await;
376 s
377 }
378
379 pub async fn shutdown(path: &Path, force: bool) -> Result<(), TransportError> {
380 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
381 c.shutdown(force).await
382 }
383
384 pub async fn purge(path: &Path) -> Result<Pong, TransportError> {
385 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
386 let p = c.purge().await;
387 c.bye().await;
388 p
389 }
390
391 pub async fn reload(path: &Path, now: bool, drain_secs: Option<u64>, exe: Option<PathBuf>) -> Result<u64, TransportError> {
395 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
396 c.writer
397 .send(ClientFrame::Reload { now, drain_secs, exe })
398 .await
399 .map_err(|_| TransportError::Closed)?;
400 let budget = Duration::from_secs(drain_secs.unwrap_or(30) + 60);
402 let frame = tokio::time::timeout(budget, read_frame(&mut c.reader, &mut c.buf))
403 .await
404 .map_err(|_| TransportError::Protocol("reload reply timed out".into()))??;
405 match frame {
406 Some(DaemonFrame::Bye { reason: Some(ByeReason::Reloading { generation, .. }) }) => Ok(generation),
407 Some(DaemonFrame::Bye { .. }) => Err(TransportError::Protocol("daemon said bye without a reload reason".into())),
408 Some(DaemonFrame::Refused { reason: RefuseReason::ReloadRefused { why }, .. }) => Err(TransportError::Refused(why)),
409 Some(DaemonFrame::Refused { message, .. }) | Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Refused(message)),
410 Some(other) => Err(TransportError::Protocol(format!("expected bye, got {other:?}"))),
411 None => Err(TransportError::Closed),
412 }
413 }
414
415 pub async fn detach(&self) {
417 let _ = self
418 .writer
419 .send(ClientFrame::Cmd { session_id: self.session.clone(), cmd: SessionCommand::Detach { client: self.client } })
420 .await;
421 let _ = self.writer.send(ClientFrame::Bye).await;
422 }
423
424 fn notice(&self, text: String) -> Envelope {
425 Envelope { session_id: self.session.clone(), seq: u64::MAX, ts: chrono::Utc::now(), event: SessionEventWire::SystemNotice(text) }
426 }
427
428 pub fn messages(&self) -> &[crate::SharedMessage] {
431 &self.messages
432 }
433
434 pub fn last_frame_bytes(&self) -> usize {
437 self.buf.last_frame_bytes
438 }
439
440 pub fn history_mode(&self) -> HistoryMode {
441 self.history
442 }
443
444 fn absorb(&mut self, w: WireEnvelope) -> Option<Envelope> {
449 if self.history == HistoryMode::Digest {
450 return self.absorb_digest(w);
451 }
452 match w.event {
453 WireSessionEvent::Conversation { digest } => {
454 if digest.matches(&self.messages) {
455 let event = SessionEventWire::Conversation(digest.into_snapshot(self.messages.clone()));
456 return Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event });
457 }
458 if digest.messages_len == 0 {
459 self.messages.clear();
460 let event = SessionEventWire::Conversation(digest.into_snapshot(Vec::new()));
461 return Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event });
462 }
463 let first = self.pending_digest.is_none();
465 self.pending_digest = Some(digest);
466 if first {
467 let _ = self.writer.try_send(ClientFrame::Cmd {
468 session_id: self.session.clone(),
469 cmd: SessionCommand::Query { id: DIGEST_RESYNC_QUERY_ID, query: SessionQuery::Messages },
470 });
471 }
472 None
473 }
474 WireSessionEvent::QueryResult { id, value } if id == DIGEST_RESYNC_QUERY_ID => {
475 if let Ok(msgs) = serde_json::from_value::<Vec<serde_json::Value>>(value) {
476 self.messages = msgs.into_iter().map(Arc::new).collect();
477 }
478 let digest = self.pending_digest.take()?;
479 if !digest.matches(&self.messages) {
480 tracing::debug!("socket_transport: messages re-query still disagrees with digest; using fetched history");
481 }
482 let event = SessionEventWire::Conversation(digest.into_snapshot(self.messages.clone()));
483 Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event })
484 }
485 WireSessionEvent::QueryResult { id, .. } if id >= RESERVED_QUERY_ID_BASE => None,
486 event => {
487 let env = Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event: event.into() };
488 match &env.event {
489 SessionEventWire::Stream(crate::StreamEvent::Session(crate::SessionEvent::MessageHistory(m))) => {
490 self.messages = m.clone();
491 }
492 SessionEventWire::Attached { snapshot, .. } => {
493 self.messages = snapshot.conversation.api_messages.clone();
494 }
495 _ => {}
496 }
497 Some(env)
498 }
499 }
500 }
501}
502
503impl SocketTransport {
504 fn absorb_digest(&mut self, w: WireEnvelope) -> Option<Envelope> {
509 match w.event {
510 WireSessionEvent::Conversation { digest } => {
511 let event = SessionEventWire::Conversation(digest.into_snapshot(Vec::new()));
512 Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event })
513 }
514 WireSessionEvent::QueryResult { id, .. } if id >= RESERVED_QUERY_ID_BASE => None,
515 event => {
516 let env = Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event: event.into() };
517 if matches!(
518 env.event,
519 SessionEventWire::Stream(crate::StreamEvent::Session(crate::SessionEvent::MessageHistory(_)))
520 ) {
521 return None;
522 }
523 Some(env)
524 }
525 }
526 }
527}
528
529#[async_trait::async_trait]
530impl ClientTransport for SocketTransport {
531 fn session_id(&self) -> &SessionId {
532 &self.session
533 }
534
535 fn meta(&self) -> &SessionMeta {
536 &self.meta
537 }
538
539 async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError> {
540 match self.writer.try_send(ClientFrame::Cmd { session_id: self.session.clone(), cmd }) {
541 Ok(()) => Ok(()),
542 Err(mpsc::error::TrySendError::Full(_)) => Err(TransportError::Backpressure),
543 Err(mpsc::error::TrySendError::Closed(_)) => Err(TransportError::Closed),
544 }
545 }
546
547 async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError> {
550 self.send(cmd).await
551 }
552
553 async fn next_event(&mut self) -> Option<Envelope> {
554 if self.ended {
555 return None;
556 }
557 loop {
558 match read_frame(&mut self.reader, &mut self.buf).await {
559 Ok(Some(DaemonFrame::Event(w))) => {
560 let Some(env) = self.absorb(w) else { continue };
561 match &env.event {
562 SessionEventWire::SettingChanged(applied) => {
563 self.view.store(Arc::new(applied.view.clone()));
564 }
565 SessionEventWire::Ended { .. } => self.ended = true,
566 SessionEventWire::InputOwnerChanged { to, .. } => self.input_owner = *to,
567 SessionEventWire::Reloading { generation, .. } => {
568 self.reload_pending = Some(*generation);
569 }
570 _ => {}
571 }
572 return Some(env);
573 }
574 Ok(Some(DaemonFrame::Error { message, .. })) => return Some(self.notice(message)),
575 Ok(Some(DaemonFrame::Bye { reason })) => {
576 if let Some(ByeReason::Reloading { generation, .. }) = reason {
577 self.reload_pending = Some(generation);
578 }
579 self.ended = true;
580 if let Some(generation) = self.reload_pending {
581 self.last_error = Some(TransportError::Reloading { generation });
582 }
583 return None;
584 }
585 Ok(None) => {
586 self.ended = true;
587 if let Some(generation) = self.reload_pending {
588 self.last_error = Some(TransportError::Reloading { generation });
589 }
590 return None;
591 }
592 Ok(Some(other)) => {
593 tracing::debug!("socket_transport: unexpected frame after attach: {other:?}");
594 continue;
595 }
596 Err(e) => {
597 tracing::debug!("socket_transport: read error: {e}");
598 self.ended = true;
599 return None;
600 }
601 }
602 }
603 }
604
605 fn view(&self) -> Arc<RuntimeView> {
606 Arc::clone(&self.view.load())
607 }
608
609 fn client_id(&self) -> ClientId {
610 self.client
611 }
612
613 fn mode(&self) -> AttachMode {
614 self.mode
615 }
616
617 fn input_owner(&self) -> Option<ClientId> {
618 self.input_owner
619 }
620
621 async fn reconnect(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
628 let was_owner = self.input_owner == Some(self.client);
629 let generation = self.reload_pending.unwrap_or(self.welcome.generation);
630 let mut hello = self.hello.clone();
631 hello.reconnect_of = Some(ClientReconnect {
632 previous_client: self.client,
633 session_id: self.session.clone(),
634 was_owner,
635 generation,
636 });
637 let mode = if was_owner { AttachMode::Takeover } else { mode };
638 let total = std::env::var("SYNAPS_TUI_ATTACH_RECONNECT_SECS")
639 .ok()
640 .and_then(|s| s.parse().ok())
641 .map(Duration::from_secs)
642 .unwrap_or(Duration::from_secs(60));
643 let deadline = tokio::time::Instant::now() + total;
644 let mut backoff = Duration::from_millis(100);
645 let mut last;
646 loop {
647 match Connected::connect(&self.path, hello.clone()).await {
648 Ok(conn) => {
649 match Self::attach(conn, Attach::Existing { session_id: self.session.clone(), mode }).await {
650 Ok((t, snap)) => {
651 *self = t;
652 return Ok(snap);
653 }
654 Err(TransportError::Refused(m)) => return Err(TransportError::Refused(m)),
655 Err(e) => last = e,
656 }
657 }
658 Err(TransportError::Version { client, daemon }) => {
659 return Err(TransportError::Version { client, daemon })
660 }
661 Err(e) => last = e,
662 }
663 if tokio::time::Instant::now() + backoff > deadline {
664 return Err(last);
665 }
666 tokio::time::sleep(backoff).await;
667 backoff = (backoff * 2).min(Duration::from_secs(5));
668 }
669 }
670
671 async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
672 SocketTransport::reconnect_once(self, mode).await
674 }
675
676 fn is_reload_pending(&self) -> bool {
677 SocketTransport::is_reload_pending(self)
678 }
679
680 fn daemon_pid(&self) -> u32 {
681 SocketTransport::daemon_pid(self)
682 }
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688 use crate::{LlmEvent, StreamEvent};
689 use tokio::net::UnixListener;
690
691 async fn fake_daemon(listener: UnixListener, protocol_version: u32) {
694 let (stream, _) = listener.accept().await.unwrap();
695 let (r, mut w) = stream.into_split();
696 let mut reader = BufReader::new(r);
697 let mut line = String::new();
698 let mut seq = 0u64;
699 let sid = SessionId::from("fake-1");
700 loop {
701 line.clear();
702 if reader.read_line(&mut line).await.unwrap() == 0 {
703 break;
704 }
705 let frame: ClientFrame = decode_line(line.trim_end()).unwrap();
706 let reply = match frame {
707 ClientFrame::Hello(h) => {
708 if h.protocol_version != protocol_version {
709 let f = DaemonFrame::Refused {
710 reason: RefuseReason::Version { daemon_version: protocol_version, min: protocol_version, max: protocol_version },
711 message: "version".into(),
712 };
713 w.write_all(encode_line(&f).unwrap().as_bytes()).await.unwrap();
714 break;
715 }
716 DaemonFrame::Welcome(Welcome {
717 protocol_version,
718 daemon_version: "t".into(),
719 pid: 1,
720 profile: None,
721 sessions: vec![],
722 progressive_tool_disclosure: true,
723 generation: 1,
724 })
725 }
726 ClientFrame::Ping => DaemonFrame::Pong { pid: 1, uptime_s: 0, sessions: 0 },
727 ClientFrame::Attach(_) => DaemonFrame::Attached(AttachedWire::new(
728 ClientId(7),
729 AttachSnapshot {
730 meta: crate::session::handle::echo::meta_for(&sid),
731 view: crate::session::handle::echo::view_for(),
732 conversation: ConversationSnapshot::default(),
733 streaming: false,
734 replay: vec![],
735 pending_prompts: vec![],
736 clients: vec![],
737 input_owner: Some(ClientId(7)),
738 display_tail: None,
739 },
740 )),
741 ClientFrame::Cmd { cmd: SessionCommand::Submit { text, .. }, .. } => {
742 seq += 1;
743 DaemonFrame::Event(WireEnvelope {
744 session_id: sid.clone(),
745 seq,
746 ts: chrono::Utc::now(),
747 event: SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(text))).into(),
748 })
749 }
750 ClientFrame::Cmd { cmd: SessionCommand::Set { id, .. }, .. } => {
751 seq += 1;
752 let mut view = crate::session::handle::echo::view_for();
753 view.model = "changed".into();
754 DaemonFrame::Event(WireEnvelope {
755 session_id: sid.clone(),
756 seq,
757 ts: chrono::Utc::now(),
758 event: SessionEventWire::SettingChanged(SettingApplied {
759 id,
760 setting: "model".into(),
761 ok: true,
762 message: None,
763 view,
764 clamp: None,
765 })
766 .into(),
767 })
768 }
769 ClientFrame::Cmd { cmd: SessionCommand::Checkpoint { .. }, .. } => {
771 seq += 1;
772 let ev = DaemonFrame::Event(WireEnvelope {
773 session_id: sid.clone(),
774 seq,
775 ts: chrono::Utc::now(),
776 event: WireSessionEvent::Reloading { generation: 2, retry_after_ms: 500 },
777 });
778 w.write_all(encode_line(&ev).unwrap().as_bytes()).await.unwrap();
779 let bye = DaemonFrame::Bye { reason: Some(ByeReason::Reloading { generation: 2, retry_after_ms: 500 }) };
780 w.write_all(encode_line(&bye).unwrap().as_bytes()).await.unwrap();
781 break;
782 }
783 ClientFrame::Cmd { cmd: SessionCommand::KeepWarm { .. }, .. } => {
785 seq += 1;
786 DaemonFrame::Event(WireEnvelope {
787 session_id: sid.clone(),
788 seq,
789 ts: chrono::Utc::now(),
790 event: WireSessionEvent::InputOwnerChanged {
791 from: Some(ClientId(7)),
792 to: Some(ClientId(9)),
793 reason: OwnerChangeReason::Takeover,
794 },
795 })
796 }
797 ClientFrame::Cmd { cmd: SessionCommand::Steer { text }, .. } => {
799 seq += 1;
800 let snap = ConversationSnapshot {
801 api_messages: vec![Arc::new(serde_json::json!({"role":"user","content":text}))],
802 cost: 1.5,
803 ..Default::default()
804 };
805 DaemonFrame::Event(WireEnvelope {
806 session_id: sid.clone(),
807 seq,
808 ts: chrono::Utc::now(),
809 event: SessionEventWire::Conversation(snap).into(),
810 })
811 }
812 ClientFrame::Cmd { cmd: SessionCommand::Query { id, query: SessionQuery::Messages }, .. } => {
813 seq += 1;
814 DaemonFrame::Event(WireEnvelope {
815 session_id: sid.clone(),
816 seq,
817 ts: chrono::Utc::now(),
818 event: WireSessionEvent::QueryResult {
819 id,
820 value: serde_json::json!([{"role":"user","content":"drifted"}]),
821 },
822 })
823 }
824 ClientFrame::Bye => {
825 let _ = w.write_all(encode_line(&DaemonFrame::Bye { reason: None }).unwrap().as_bytes()).await;
826 break;
827 }
828 ClientFrame::Cmd { cmd: SessionCommand::Detach { .. }, .. } => continue,
829 _ => DaemonFrame::Error { session_id: None, message: "nope".into() },
830 };
831 w.write_all(encode_line(&reply).unwrap().as_bytes()).await.unwrap();
832 }
833 }
834
835 fn sock() -> (tempfile::TempDir, std::path::PathBuf) {
836 let d = tempfile::tempdir().unwrap();
837 let p = d.path().join("d.sock");
838 (d, p)
839 }
840
841 #[tokio::test]
842 async fn socket_transport_handshake_attach_pump() {
843 let (_d, path) = sock();
844 let l = UnixListener::bind(&path).unwrap();
845 let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
846
847 let mut conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
848 assert_eq!(conn.welcome.protocol_version, PROTOCOL_VERSION);
849 assert_eq!(conn.ping().await.unwrap().pid, 1);
850 let (mut t, snap) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
851 .await
852 .unwrap();
853 assert_eq!(t.client_id(), ClientId(7));
854 assert_eq!(t.mode(), AttachMode::Mirror);
855 assert_eq!(t.input_owner(), Some(ClientId(7)));
856 assert_eq!(snap.meta.id.as_str(), "fake-1");
857 assert_eq!(t.session_id().as_str(), "fake-1");
858 assert_eq!(t.view().model, "echo");
859
860 t.send(SessionCommand::Submit { text: "hello".into(), attachments: vec![] }).await.unwrap();
861 let e = t.next_event().await.unwrap();
862 match e.event {
863 SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(s))) => assert_eq!(s, "hello"),
864 other => panic!("{other:?}"),
865 }
866 t.send_from_self(SessionCommand::Set { id: 11, setting: SessionSetting::Model { model: "changed".into() } })
867 .await
868 .unwrap();
869 let e = t.next_event().await.unwrap();
870 assert!(matches!(e.event, SessionEventWire::SettingChanged(SettingApplied { id: 11, .. })));
871 assert_eq!(t.view().model, "changed");
872 t.send(SessionCommand::KeepWarm { on: true }).await.unwrap();
873 let e = t.next_event().await.unwrap();
874 assert!(matches!(e.event, SessionEventWire::InputOwnerChanged { .. }));
875 assert_eq!(t.input_owner(), Some(ClientId(9)));
876
877 t.send(SessionCommand::Save).await.unwrap();
879 assert!(matches!(t.next_event().await.unwrap().event, SessionEventWire::SystemNotice(_)));
880 t.detach().await;
881 assert!(t.next_event().await.is_none());
882 assert!(t.next_event().await.is_none());
883 srv.await.unwrap();
884 }
885
886 #[tokio::test]
887 async fn conversation_digest_miss_requeries_messages() {
888 let (_d, path) = sock();
889 let l = UnixListener::bind(&path).unwrap();
890 let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
891 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
892 let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
893 .await
894 .unwrap();
895 assert!(t.messages().is_empty());
896 t.send(SessionCommand::Steer { text: "drifted".into() }).await.unwrap();
897 let e = t.next_event().await.unwrap();
900 match e.event {
901 SessionEventWire::Conversation(c) => {
902 assert_eq!(c.api_messages.len(), 1);
903 assert_eq!(c.api_messages[0]["content"], "drifted");
904 assert_eq!(c.cost, 1.5);
905 }
906 other => panic!("{other:?}"),
907 }
908 assert_eq!(t.messages().len(), 1);
909 t.send(SessionCommand::Steer { text: "drifted".into() }).await.unwrap();
911 let e = t.next_event().await.unwrap();
912 assert!(matches!(e.event, SessionEventWire::Conversation(ref c) if c.api_messages.len() == 1));
913 t.detach().await;
914 assert!(t.next_event().await.is_none());
915 srv.await.unwrap();
916 }
917
918 #[tokio::test]
919 async fn reloading_then_bye_sets_last_error_and_reconnect_gives_up_on_a_dead_socket() {
920 let (_d, path) = sock();
921 let l = UnixListener::bind(&path).unwrap();
922 let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
923 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
924 let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Observe })
925 .await
926 .unwrap();
927 assert_eq!(t.mode(), AttachMode::Observe);
928 assert!(t.last_error().is_none());
929 t.send(SessionCommand::Checkpoint { reason: CheckpointReason::Reload }).await.unwrap();
930 let e = t.next_event().await.unwrap();
931 assert!(matches!(e.event, SessionEventWire::Reloading { generation: 2, .. }));
932 assert!(t.next_event().await.is_none());
933 assert!(matches!(t.last_error(), Some(TransportError::Reloading { generation: 2 })));
934 srv.await.unwrap();
935 std::env::set_var("SYNAPS_TUI_ATTACH_RECONNECT_SECS", "1");
937 let r = t.reconnect(AttachMode::Observe).await;
938 std::env::remove_var("SYNAPS_TUI_ATTACH_RECONNECT_SECS");
939 assert!(r.is_err());
940 }
941
942 #[tokio::test]
943 async fn attach_error_frame_is_refused() {
944 let (_d, path) = sock();
945 let l = UnixListener::bind(&path).unwrap();
946 let srv = tokio::spawn(async move {
948 let (stream, _) = l.accept().await.unwrap();
949 let (r, mut w) = stream.into_split();
950 let mut reader = BufReader::new(r);
951 let mut line = String::new();
952 reader.read_line(&mut line).await.unwrap();
953 let welcome = DaemonFrame::Welcome(Welcome {
954 protocol_version: PROTOCOL_VERSION,
955 daemon_version: "t".into(),
956 pid: 1,
957 profile: None,
958 sessions: vec![],
959 progressive_tool_disclosure: true,
960 generation: 1,
961 });
962 w.write_all(encode_line(&welcome).unwrap().as_bytes()).await.unwrap();
963 line.clear();
964 reader.read_line(&mut line).await.unwrap();
965 let err = DaemonFrame::Error { session_id: None, message: "unknown session".into() };
966 w.write_all(encode_line(&err).unwrap().as_bytes()).await.unwrap();
967 });
968 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
969 let err = SocketTransport::attach(conn, Attach::Existing { session_id: "nope".into(), mode: AttachMode::Mirror })
970 .await
971 .err()
972 .unwrap();
973 assert!(matches!(err, TransportError::Refused(ref m) if m == "unknown session"), "{err}");
974 srv.await.unwrap();
975 }
976
977 #[tokio::test]
978 async fn socket_transport_refuses_version_mismatch() {
979 let (_d, path) = sock();
980 let l = UnixListener::bind(&path).unwrap();
981 let srv = tokio::spawn(fake_daemon(l, 99));
982 let err = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.err().unwrap();
983 assert!(matches!(err, TransportError::Version { client: PROTOCOL_VERSION, daemon: 99 }), "{err}");
984 srv.await.unwrap();
985 }
986
987 #[tokio::test]
988 async fn socket_transport_connect_refused_when_nobody_listens() {
989 let (_d, path) = sock();
990 let err = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.err().unwrap();
991 assert!(matches!(err, TransportError::Io(_)));
992 }
993}