1use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13
14use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
15use tokio::sync::mpsc;
16
17use super::handle::CMD_CHAN_CAP;
18use super::transport::{ClientTransport, TransportError, ATTACH_TIMEOUT, ATTACH_TIMEOUT_PARKED};
19use super::types::*;
20use super::view::RuntimeView;
21use super::wire::*;
22use crate::ipc::{ClientReadHalf, ClientWriteHalf};
23
24pub const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
25pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2);
26pub const CLOSE_TIMEOUT: Duration = Duration::from_secs(
30 crate::daemon::close::CLOSE_SEND_RETRY.as_secs()
31 + crate::daemon::close::CLOSE_REPLY_BUDGET.as_secs()
32 + crate::daemon::close::CLOSE_END_BUDGET.as_secs()
33 + 2,
34);
35pub const PIN_TIMEOUT: Duration =
38 Duration::from_secs(crate::daemon::pin::PIN_UNPARK_BUDGET.as_secs() + 2);
39pub const RENAME_TIMEOUT: Duration = Duration::from_secs(
42 crate::daemon::rename::RENAME_SEND_RETRY.as_secs() + crate::daemon::rename::RENAME_REPLY_BUDGET.as_secs() + 2,
43);
44pub const RELOAD_EXTENSION_TIMEOUT: Duration = Duration::from_secs(60);
50
51pub struct Connected {
53 reader: BufReader<ClientReadHalf>,
54 writer: mpsc::Sender<ClientFrame>,
55 writer_task: tokio::task::JoinHandle<()>,
56 pub welcome: Welcome,
57 path: PathBuf,
59 hello: Hello,
60 buf: FrameBuf,
61}
62
63pub struct Pong {
64 pub pid: u32,
65 pub uptime_s: u64,
66 pub sessions: usize,
67}
68
69#[derive(Debug)]
71pub enum AuthStateReply {
72 Report(agent_core::auth::AuthStateReport),
73 DaemonPredates { daemon_version: String, daemon_build: String },
77}
78
79pub fn predates_auth_state(message: &str) -> bool {
82 message.starts_with("malformed frame: unknown variant `auth_state`")
83}
84
85#[derive(Default)]
89pub struct FrameBuf {
90 line: Vec<u8>,
91 last_frame_bytes: usize,
93}
94
95const FRAME_BUF_KEEP: usize = 64 * 1024;
96const FRAME_BUF_SHRINK_ABOVE: usize = 1024 * 1024;
97
98impl FrameBuf {
99 fn after_frame(&mut self, n: usize) {
100 self.last_frame_bytes = n;
101 if self.line.capacity() > FRAME_BUF_SHRINK_ABOVE {
102 self.line = Vec::new();
103 self.line.reserve(FRAME_BUF_KEEP);
104 }
105 }
106}
107
108async fn read_frame(reader: &mut BufReader<ClientReadHalf>, buf: &mut FrameBuf) -> Result<Option<DaemonFrame>, TransportError> {
109 loop {
113 let limit = (MAX_FRAME_BYTES as u64 + 1).saturating_sub(buf.line.len() as u64);
115 let n = tokio::io::AsyncReadExt::take(&mut *reader, limit)
116 .read_until(b'\n', &mut buf.line)
117 .await?;
118 if n == 0 {
119 return Ok(None);
120 }
121 if buf.line.len() > MAX_FRAME_BYTES {
122 buf.line.clear();
123 return Err(TransportError::Protocol(frame_limit_msg()));
124 }
125 let decoded = {
126 let t = std::str::from_utf8(&buf.line)
127 .map_err(|e| TransportError::Protocol(format!("frame is not utf-8: {e}")))
128 .map(str::trim_end);
129 match t {
130 Ok("") => None,
131 Ok(t) => Some((decode_line::<DaemonFrame>(t).map(Some).map_err(TransportError::Protocol), t.len())),
132 Err(e) => Some((Err(e), 0)),
133 }
134 };
135 buf.line.clear();
136 let Some((decoded, len)) = decoded else { continue };
137 buf.after_frame(len);
138 return decoded;
139 }
140}
141
142fn spawn_writer(mut w: ClientWriteHalf) -> (mpsc::Sender<ClientFrame>, tokio::task::JoinHandle<()>) {
143 let (tx, mut rx) = mpsc::channel::<ClientFrame>(CMD_CHAN_CAP);
144 let task = tokio::spawn(async move {
145 while let Some(frame) = rx.recv().await {
146 let line = match encode_line(&frame) {
147 Ok(l) => l,
148 Err(e) => {
149 tracing::warn!(frame = ?frame, "socket_transport: encode failed: {e}");
150 continue;
151 }
152 };
153 if let Err(e) = w.write_all(line.as_bytes()).await {
154 tracing::debug!("socket_transport: write failed: {e}");
155 break;
156 }
157 if matches!(frame, ClientFrame::Bye) {
158 let _ = w.shutdown().await;
159 break;
160 }
161 }
162 });
163 (tx, task)
164}
165
166impl Connected {
167 pub async fn connect(path: &Path, hello: Hello) -> Result<Self, TransportError> {
170 let stream = tokio::time::timeout(CONNECT_TIMEOUT, crate::ipc::connect(path))
171 .await
172 .map_err(|_| TransportError::Protocol("connect timed out".into()))??;
173 let (r, w) = crate::ipc::split_client(stream);
174 let mut reader = BufReader::new(r);
175 let (writer, writer_task) = spawn_writer(w);
176 let client_version = hello.protocol_version;
177 let hello_copy = hello.clone();
178 let mut buf = FrameBuf::default();
179 writer
180 .send(ClientFrame::Hello(hello))
181 .await
182 .map_err(|_| TransportError::Closed)?;
183 let frame = tokio::time::timeout(HANDSHAKE_TIMEOUT, read_frame(&mut reader, &mut buf))
184 .await
185 .map_err(|_| TransportError::Protocol("handshake timed out".into()))??;
186 match frame {
187 Some(DaemonFrame::Welcome(welcome)) => {
188 if welcome.protocol_version != client_version {
189 return Err(TransportError::Version { client: client_version, daemon: welcome.protocol_version });
190 }
191 Ok(Self { reader, writer, writer_task, welcome, path: path.to_path_buf(), hello: hello_copy, buf })
192 }
193 Some(DaemonFrame::Refused { reason: RefuseReason::Version { daemon_version, .. }, .. }) => {
194 Err(TransportError::Version { client: client_version, daemon: daemon_version })
195 }
196 Some(DaemonFrame::Refused { reason, message }) => {
197 Err(TransportError::Protocol(format!("refused ({reason:?}): {message}")))
198 }
199 Some(other) => Err(TransportError::Protocol(format!("expected welcome, got {other:?}"))),
200 None => Err(TransportError::Closed),
201 }
202 }
203
204 async fn request(&mut self, frame: ClientFrame) -> Result<DaemonFrame, TransportError> {
205 self.writer.send(frame).await.map_err(|_| TransportError::Closed)?;
206 tokio::time::timeout(HANDSHAKE_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
207 .await
208 .map_err(|_| TransportError::Protocol("reply timed out".into()))??
209 .ok_or(TransportError::Closed)
210 }
211
212 pub async fn ping(&mut self) -> Result<Pong, TransportError> {
213 match self.request(ClientFrame::Ping).await? {
214 DaemonFrame::Pong { pid, uptime_s, sessions } => Ok(Pong { pid, uptime_s, sessions }),
215 other => Err(TransportError::Protocol(format!("expected pong, got {other:?}"))),
216 }
217 }
218
219 pub async fn sessions(&mut self) -> Result<Vec<SessionMeta>, TransportError> {
220 match self.request(ClientFrame::Sessions).await? {
221 DaemonFrame::SessionList { sessions } => Ok(sessions),
222 other => Err(TransportError::Protocol(format!("expected session list, got {other:?}"))),
223 }
224 }
225
226 pub async fn purge(&mut self) -> Result<Pong, TransportError> {
228 match self.request(ClientFrame::Purge).await? {
229 DaemonFrame::Pong { pid, uptime_s, sessions } => Ok(Pong { pid, uptime_s, sessions }),
230 DaemonFrame::Error { message, .. } => Err(TransportError::Protocol(message)),
231 other => Err(TransportError::Protocol(format!("expected pong, got {other:?}"))),
232 }
233 }
234
235 pub async fn auth_state(&mut self, query: agent_core::auth::AuthStateQuery) -> Result<AuthStateReply, TransportError> {
239 match self.request(ClientFrame::AuthState(query)).await? {
240 DaemonFrame::AuthState { report } => Ok(AuthStateReply::Report(report)),
241 DaemonFrame::Error { session_id: None, message } if predates_auth_state(&message) => {
242 Ok(AuthStateReply::DaemonPredates {
243 daemon_version: self.welcome.daemon_version.clone(),
244 daemon_build: self.welcome.daemon_build.clone(),
245 })
246 }
247 DaemonFrame::Error { message, .. } => Err(TransportError::Protocol(message)),
248 other => Err(TransportError::Protocol(format!("expected auth state, got {other:?}"))),
249 }
250 }
251
252 pub async fn shutdown(&mut self, force: bool) -> Result<(), TransportError> {
253 match self.request(ClientFrame::Shutdown { force }).await? {
254 DaemonFrame::Bye { .. } => Ok(()),
255 DaemonFrame::Error { message, .. } => Err(TransportError::Protocol(message)),
256 other => Err(TransportError::Protocol(format!("expected bye, got {other:?}"))),
257 }
258 }
259
260 pub async fn close(&mut self, id: &SessionId, force: bool, attached: bool) -> Result<String, TransportError> {
268 self.writer
269 .send(ClientFrame::Close { session_id: id.clone(), force, attached })
270 .await
271 .map_err(|_| TransportError::Closed)?;
272 let frame = tokio::time::timeout(CLOSE_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
273 .await
274 .map_err(|_| TransportError::Protocol("close reply timed out".into()))??;
275 close_reply(frame, &self.welcome.daemon_version)
276 }
277
278 pub async fn pin(&mut self, id: &SessionId, on: bool) -> Result<bool, TransportError> {
284 self.writer
285 .send(ClientFrame::Pin { session_id: id.clone(), on })
286 .await
287 .map_err(|_| TransportError::Closed)?;
288 let frame = tokio::time::timeout(PIN_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
289 .await
290 .map_err(|_| TransportError::Protocol("pin reply timed out".into()))??;
291 pin_reply(frame, &self.welcome.daemon_version)
292 }
293
294 pub async fn rename(
303 &mut self,
304 id: &SessionId,
305 name: Option<&str>,
306 expect: Option<Option<String>>,
307 ) -> Result<Result<Renamed, RenameFailure>, TransportError> {
308 self.writer
309 .send(ClientFrame::Rename { session_id: id.clone(), name: name.map(str::to_string), expect })
310 .await
311 .map_err(|_| TransportError::Closed)?;
312 let unknown = |why: &str| {
313 Ok(Err(RenameFailure {
314 reason: crate::session::wire::RenameRefusal::Unknown,
315 message: format!("{why}; the rename may still apply. Re-list (`synaps daemon sessions`) before retrying"),
316 taken_by: None,
317 current_name: None,
318 }))
319 };
320 match tokio::time::timeout(RENAME_TIMEOUT, read_frame(&mut self.reader, &mut self.buf)).await {
321 Err(_) => unknown("no reply from the daemon in time"),
322 Ok(Ok(None)) | Ok(Err(TransportError::Closed)) => unknown("the daemon closed the connection before answering"),
323 Ok(frame) => rename_reply(frame?, &self.welcome.daemon_version),
324 }
325 }
326
327 pub async fn reload_extension(
333 &mut self,
334 extension: &str,
335 ) -> Result<Result<ExtensionReloadReport, ExtensionReloadFailure>, TransportError> {
336 self.writer
337 .send(ClientFrame::ReloadExtension { extension: extension.to_string() })
338 .await
339 .map_err(|_| TransportError::Closed)?;
340 let frame = tokio::time::timeout(RELOAD_EXTENSION_TIMEOUT, read_frame(&mut self.reader, &mut self.buf))
341 .await
342 .map_err(|_| TransportError::Protocol("extension reload reply timed out".into()))??;
343 reload_extension_reply(frame, &self.welcome.daemon_version)
344 }
345
346 pub async fn bye(self) {
348 let _ = self.writer.send(ClientFrame::Bye).await;
349 let _ = tokio::time::timeout(Duration::from_millis(200), self.writer_task).await;
350 }
351}
352
353fn close_reply(frame: Option<DaemonFrame>, daemon_version: &str) -> Result<String, TransportError> {
355 match frame {
356 Some(DaemonFrame::Closed { text, .. }) => Ok(text),
357 Some(DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message }) => {
358 Err(TransportError::UnknownSession(message))
359 }
360 Some(DaemonFrame::Refused { message, .. }) => Err(TransportError::Refused(message)),
361 Some(DaemonFrame::Error { message, .. }) if message.contains("unknown variant `close`") => {
364 Err(TransportError::DaemonTooOld { daemon_version: daemon_version.to_string(), request: "daemon close" })
365 }
366 Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Protocol(message)),
367 Some(other) => Err(TransportError::Protocol(format!("expected closed, got {other:?}"))),
368 None => Err(TransportError::Closed),
369 }
370}
371
372fn pin_reply(frame: Option<DaemonFrame>, daemon_version: &str) -> Result<bool, TransportError> {
374 match frame {
375 Some(DaemonFrame::Pinned { on, .. }) => Ok(on),
376 Some(DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message }) => {
377 Err(TransportError::UnknownSession(message))
378 }
379 Some(DaemonFrame::Refused { message, .. }) => Err(TransportError::Refused(message)),
380 Some(DaemonFrame::Error { message, .. }) if message.contains("unknown variant `pin`") => {
383 Err(TransportError::DaemonTooOld { daemon_version: daemon_version.to_string(), request: "daemon pin" })
384 }
385 Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Protocol(message)),
386 Some(other) => Err(TransportError::Protocol(format!("expected pinned, got {other:?}"))),
387 None => Err(TransportError::Closed),
388 }
389}
390
391#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct Renamed {
394 pub name: Option<String>,
396 pub in_memory_only: bool,
398}
399
400#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct RenameFailure {
403 pub reason: crate::session::wire::RenameRefusal,
404 pub message: String,
405 pub taken_by: Option<String>,
407 pub current_name: Option<String>,
409}
410
411fn rename_reply(
413 frame: Option<DaemonFrame>,
414 daemon_version: &str,
415) -> Result<Result<Renamed, RenameFailure>, TransportError> {
416 match frame {
417 Some(DaemonFrame::Renamed { name, in_memory_only, .. }) => Ok(Ok(Renamed { name, in_memory_only })),
418 Some(DaemonFrame::RenameFailed { reason, message, taken_by, current_name, .. }) => {
419 Ok(Err(RenameFailure { reason, message, taken_by, current_name }))
420 }
421 Some(DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message }) => {
422 Err(TransportError::UnknownSession(message))
423 }
424 Some(DaemonFrame::Refused { message, .. }) => Err(TransportError::Refused(message)),
425 Some(DaemonFrame::Error { message, .. }) if message.contains("unknown variant `rename`") => {
428 Err(TransportError::DaemonTooOld { daemon_version: daemon_version.to_string(), request: "daemon rename" })
429 }
430 Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Protocol(message)),
431 Some(other) => Err(TransportError::Protocol(format!("expected renamed, got {other:?}"))),
432 None => Err(TransportError::Closed),
433 }
434}
435
436pub use crate::extensions::manager::ExtensionReloadReport;
437
438#[derive(Debug, Clone, PartialEq, Eq)]
441pub struct ExtensionReloadFailure {
442 pub message: String,
443 pub unloaded: bool,
447}
448
449fn reload_extension_reply(
452 frame: Option<DaemonFrame>,
453 daemon_version: &str,
454) -> Result<Result<ExtensionReloadReport, ExtensionReloadFailure>, TransportError> {
455 match frame {
456 Some(DaemonFrame::ExtensionReloaded { report }) => Ok(Ok(report)),
457 Some(DaemonFrame::ExtensionReloadFailed { message, unloaded, .. }) => {
458 Ok(Err(ExtensionReloadFailure { message, unloaded }))
459 }
460 Some(DaemonFrame::Refused { message, .. }) => Err(TransportError::Refused(message)),
461 Some(DaemonFrame::Error { message, .. }) if message.contains("unknown variant `reload_extension`") => {
464 Err(TransportError::DaemonTooOld {
465 daemon_version: daemon_version.to_string(),
466 request: "daemon reload-extension",
467 })
468 }
469 Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Protocol(message)),
470 Some(other) => Err(TransportError::Protocol(format!("expected extension_reloaded, got {other:?}"))),
471 None => Err(TransportError::Closed),
472 }
473}
474
475pub struct SocketTransport {
476 reader: BufReader<ClientReadHalf>,
477 writer: mpsc::Sender<ClientFrame>,
478 _writer_task: tokio::task::JoinHandle<()>,
479 session: SessionId,
480 meta: SessionMeta,
481 client: ClientId,
482 view: Arc<arc_swap::ArcSwap<RuntimeView>>,
483 pub welcome: Welcome,
484 mode: AttachMode,
485 input_owner: Option<ClientId>,
486 roster: Roster,
487 client_input: ClientInput,
488 ended: bool,
489 path: PathBuf,
490 hello: Hello,
491 reload_pending: Option<u64>,
493 last_error: Option<TransportError>,
496 messages: Vec<crate::SharedMessage>,
501 pending_digest: Option<ConversationDigest>,
503 history: HistoryMode,
505 buf: FrameBuf,
506}
507
508impl SocketTransport {
509 pub async fn connect(path: &Path, hello: Hello) -> Result<Connected, TransportError> {
511 Connected::connect(path, hello).await
512 }
513
514 pub async fn attach(mut conn: Connected, attach: Attach) -> Result<(Self, AttachSnapshot), TransportError> {
518 let (mode, parked) = match &attach {
519 Attach::Existing { session_id, mode } => (
520 *mode,
521 conn.welcome
522 .sessions
523 .iter()
524 .any(|m| &m.id == session_id && m.lifecycle == SessionLifecycle::Parked),
525 ),
526 Attach::Create { mode, .. } => (*mode, false),
527 };
528 conn.writer
529 .send(ClientFrame::Attach(attach))
530 .await
531 .map_err(|_| TransportError::Closed)?;
532 let budget = if parked { ATTACH_TIMEOUT_PARKED } else { ATTACH_TIMEOUT };
533 let deadline = tokio::time::Instant::now() + budget;
534 let attached = loop {
535 let frame = tokio::time::timeout_at(deadline, read_frame(&mut conn.reader, &mut conn.buf))
536 .await
537 .map_err(|_| TransportError::Protocol("attach timed out".into()))??;
538 match frame {
539 Some(DaemonFrame::Attached(a)) => break a,
540 Some(DaemonFrame::Error { message, .. }) => return Err(TransportError::Refused(message)),
541 Some(DaemonFrame::Event(w)) => match w.event {
542 WireSessionEvent::AttachRefused { message } => return Err(TransportError::Refused(message)),
543 _ => continue,
545 },
546 Some(DaemonFrame::Bye { .. }) | None => return Err(TransportError::Closed),
547 Some(other) => return Err(TransportError::Protocol(format!("expected attached, got {other:?}"))),
548 }
549 };
550 let (client, snapshot) = attached.into_snapshot();
551 let meta = snapshot.meta.clone();
552 let view = Arc::new(arc_swap::ArcSwap::from_pointee(snapshot.view.clone()));
553 let history = conn.hello.client.history;
554 let messages = match history {
555 HistoryMode::Full => snapshot.conversation.api_messages.clone(),
556 HistoryMode::Digest => Vec::new(),
557 };
558 Ok((
559 Self {
560 reader: conn.reader,
561 writer: conn.writer,
562 _writer_task: conn.writer_task,
563 session: meta.id.clone(),
564 meta,
565 client,
566 view,
567 welcome: conn.welcome,
568 mode,
569 input_owner: snapshot.input_owner,
570 roster: Roster::from_snapshot(&snapshot),
571 client_input: snapshot.client_input,
572 ended: false,
573 path: conn.path,
574 hello: conn.hello,
575 reload_pending: None,
576 last_error: None,
577 messages,
578 pending_digest: None,
579 history,
580 buf: conn.buf,
581 },
582 snapshot,
583 ))
584 }
585
586 pub fn last_error(&self) -> Option<&TransportError> {
588 self.last_error.as_ref()
589 }
590
591 pub fn is_reload_pending(&self) -> bool {
594 self.reload_pending.is_some()
595 }
596
597 pub fn daemon_pid(&self) -> u32 {
599 self.welcome.pid
600 }
601
602 pub fn reconnect_budget() -> Duration {
604 std::env::var("SYNAPS_TUI_ATTACH_RECONNECT_SECS")
605 .ok()
606 .and_then(|s| s.parse().ok())
607 .map(Duration::from_secs)
608 .unwrap_or(Duration::from_secs(60))
609 }
610
611 pub async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
615 let was_owner = self.input_owner == Some(self.client);
616 let generation = self.reload_pending.unwrap_or(self.welcome.generation);
617 let mut hello = self.hello.clone();
618 hello.reconnect_of = Some(ClientReconnect {
619 previous_client: self.client,
620 session_id: self.session.clone(),
621 was_owner,
622 generation,
623 });
624 let mode = if was_owner { AttachMode::Takeover } else { mode };
625 match Connected::connect(&self.path, hello).await {
626 Ok(conn) => {
627 match Self::attach(conn, Attach::Existing { session_id: self.session.clone(), mode }).await {
628 Ok((t, snap)) => {
629 *self = t;
630 Ok(snap)
631 }
632 Err(e) => Err(e),
633 }
634 }
635 Err(e) => Err(e),
636 }
637 }
638
639 pub async fn ping(path: &Path) -> Result<Pong, TransportError> {
642 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
643 let p = c.ping().await;
644 c.bye().await;
645 p
646 }
647
648 pub async fn sessions(path: &Path) -> Result<Vec<SessionMeta>, TransportError> {
649 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
650 let s = c.sessions().await;
651 c.bye().await;
652 s
653 }
654
655 pub async fn shutdown(path: &Path, force: bool) -> Result<(), TransportError> {
656 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
657 c.shutdown(force).await
658 }
659
660 pub async fn purge(path: &Path) -> Result<Pong, TransportError> {
661 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
662 let p = c.purge().await;
663 c.bye().await;
664 p
665 }
666
667 pub async fn close(path: &Path, id: &SessionId, force: bool, attached: bool) -> Result<String, TransportError> {
670 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
671 let r = c.close(id, force, attached).await;
672 c.bye().await;
673 r
674 }
675
676 pub async fn pin(path: &Path, id: &SessionId, on: bool) -> Result<bool, TransportError> {
678 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
679 let r = c.pin(id, on).await;
680 c.bye().await;
681 r
682 }
683
684 pub async fn rename(
687 path: &Path,
688 id: &SessionId,
689 name: Option<&str>,
690 expect: Option<Option<String>>,
691 ) -> Result<Result<Renamed, RenameFailure>, TransportError> {
692 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
693 let r = c.rename(id, name, expect).await;
694 c.bye().await;
695 r
696 }
697
698 pub async fn auth_state(path: &Path, query: agent_core::auth::AuthStateQuery) -> Result<AuthStateReply, TransportError> {
700 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
701 let reply = c.auth_state(query).await;
702 c.bye().await;
703 reply
704 }
705
706 pub async fn reload(path: &Path, now: bool, drain_secs: Option<u64>, exe: Option<PathBuf>) -> Result<u64, TransportError> {
710 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
711 c.writer
712 .send(ClientFrame::Reload { now, drain_secs, exe })
713 .await
714 .map_err(|_| TransportError::Closed)?;
715 let budget = Duration::from_secs(drain_secs.unwrap_or(30) + 60);
717 let frame = tokio::time::timeout(budget, read_frame(&mut c.reader, &mut c.buf))
718 .await
719 .map_err(|_| TransportError::Protocol("reload reply timed out".into()))??;
720 match frame {
721 Some(DaemonFrame::Bye { reason: Some(ByeReason::Reloading { generation, .. }) }) => Ok(generation),
722 Some(DaemonFrame::Bye { .. }) => Err(TransportError::Protocol("daemon said bye without a reload reason".into())),
723 Some(DaemonFrame::Refused { reason: RefuseReason::ReloadRefused { why }, .. }) => Err(TransportError::Refused(why)),
724 Some(DaemonFrame::Refused { message, .. }) | Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Refused(message)),
725 Some(other) => Err(TransportError::Protocol(format!("expected bye, got {other:?}"))),
726 None => Err(TransportError::Closed),
727 }
728 }
729
730 pub async fn detach(&self) {
732 let _ = self
733 .writer
734 .send(ClientFrame::Cmd { session_id: self.session.clone(), cmd: SessionCommand::Detach { client: self.client } })
735 .await;
736 let _ = self.writer.send(ClientFrame::Bye).await;
737 }
738
739 fn notice(&self, text: String) -> Envelope {
740 Envelope { session_id: self.session.clone(), seq: u64::MAX, ts: chrono::Utc::now(), event: SessionEventWire::SystemNotice(text) }
741 }
742
743 pub fn messages(&self) -> &[crate::SharedMessage] {
746 &self.messages
747 }
748
749 pub fn last_frame_bytes(&self) -> usize {
752 self.buf.last_frame_bytes
753 }
754
755 pub fn history_mode(&self) -> HistoryMode {
756 self.history
757 }
758
759 fn absorb(&mut self, w: WireEnvelope) -> Option<Envelope> {
764 if self.history == HistoryMode::Digest {
765 return self.absorb_digest(w);
766 }
767 match w.event {
768 WireSessionEvent::Conversation { digest } => {
769 if digest.matches(&self.messages) {
770 let event = SessionEventWire::Conversation(digest.into_snapshot(self.messages.clone()));
771 return Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event });
772 }
773 if digest.messages_len == 0 {
774 self.messages.clear();
775 let event = SessionEventWire::Conversation(digest.into_snapshot(Vec::new()));
776 return Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event });
777 }
778 let first = self.pending_digest.is_none();
780 self.pending_digest = Some(digest);
781 if first {
782 let _ = self.writer.try_send(ClientFrame::Cmd {
783 session_id: self.session.clone(),
784 cmd: SessionCommand::Query { id: DIGEST_RESYNC_QUERY_ID, query: SessionQuery::Messages },
785 });
786 }
787 None
788 }
789 WireSessionEvent::QueryResult { id, value } if id == DIGEST_RESYNC_QUERY_ID => {
790 if let Ok(msgs) = serde_json::from_value::<Vec<serde_json::Value>>(value) {
791 self.messages = msgs.into_iter().map(Arc::new).collect();
792 }
793 let digest = self.pending_digest.take()?;
794 if !digest.matches(&self.messages) {
795 tracing::debug!("socket_transport: messages re-query still disagrees with digest; using fetched history");
796 }
797 let event = SessionEventWire::Conversation(digest.into_snapshot(self.messages.clone()));
798 Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event })
799 }
800 WireSessionEvent::QueryResult { id, .. } if id >= RESERVED_QUERY_ID_BASE => None,
801 event => {
802 let env = Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event: event.into() };
803 match &env.event {
804 SessionEventWire::Stream(crate::StreamEvent::Session(crate::SessionEvent::MessageHistory(m))) => {
805 self.messages = m.clone();
806 }
807 SessionEventWire::Attached { snapshot, .. } => {
808 self.messages = snapshot.conversation.api_messages.clone();
809 }
810 _ => {}
811 }
812 Some(env)
813 }
814 }
815 }
816}
817
818impl SocketTransport {
819 fn absorb_digest(&mut self, w: WireEnvelope) -> Option<Envelope> {
824 match w.event {
825 WireSessionEvent::Conversation { digest } => {
826 let event = SessionEventWire::Conversation(digest.into_snapshot(Vec::new()));
827 Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event })
828 }
829 WireSessionEvent::QueryResult { id, .. } if id >= RESERVED_QUERY_ID_BASE => None,
830 event => {
831 let env = Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event: event.into() };
832 if matches!(
833 env.event,
834 SessionEventWire::Stream(crate::StreamEvent::Session(crate::SessionEvent::MessageHistory(_)))
835 ) {
836 return None;
837 }
838 Some(env)
839 }
840 }
841 }
842}
843
844#[async_trait::async_trait]
845impl ClientTransport for SocketTransport {
846 fn session_id(&self) -> &SessionId {
847 &self.session
848 }
849
850 fn meta(&self) -> &SessionMeta {
851 &self.meta
852 }
853
854 async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError> {
855 match self.writer.try_send(ClientFrame::Cmd { session_id: self.session.clone(), cmd }) {
856 Ok(()) => Ok(()),
857 Err(mpsc::error::TrySendError::Full(_)) => Err(TransportError::Backpressure),
858 Err(mpsc::error::TrySendError::Closed(_)) => Err(TransportError::Closed),
859 }
860 }
861
862 async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError> {
865 self.send(cmd).await
866 }
867
868 async fn next_event(&mut self) -> Option<Envelope> {
869 if self.ended {
870 return None;
871 }
872 loop {
873 match read_frame(&mut self.reader, &mut self.buf).await {
874 Ok(Some(DaemonFrame::Event(w))) => {
875 let Some(env) = self.absorb(w) else { continue };
876 match &env.event {
877 SessionEventWire::SettingChanged(applied) => {
878 self.view.store(Arc::new(applied.view.clone()));
879 }
880 SessionEventWire::Ended { .. } => self.ended = true,
881 SessionEventWire::InputOwnerChanged { to, .. } => self.input_owner = *to,
882 SessionEventWire::Reloading { generation, .. } => {
883 self.reload_pending = Some(*generation);
884 }
885 other => self.roster.apply(other),
886 }
887 return Some(env);
888 }
889 Ok(Some(DaemonFrame::Error { message, .. })) => return Some(self.notice(message)),
890 Ok(Some(DaemonFrame::Bye { reason })) => {
891 if let Some(ByeReason::Reloading { generation, .. }) = reason {
892 self.reload_pending = Some(generation);
893 }
894 self.ended = true;
895 if let Some(generation) = self.reload_pending {
896 self.last_error = Some(TransportError::Reloading { generation });
897 }
898 return None;
899 }
900 Ok(None) => {
901 self.ended = true;
902 if let Some(generation) = self.reload_pending {
903 self.last_error = Some(TransportError::Reloading { generation });
904 }
905 return None;
906 }
907 Ok(Some(other)) => {
908 tracing::debug!("socket_transport: unexpected frame after attach: {other:?}");
909 continue;
910 }
911 Err(e) => {
912 tracing::debug!("socket_transport: read error: {e}");
913 self.ended = true;
914 return None;
915 }
916 }
917 }
918 }
919
920 fn view(&self) -> Arc<RuntimeView> {
921 Arc::clone(&self.view.load())
922 }
923
924 fn client_id(&self) -> ClientId {
925 self.client
926 }
927
928 fn mode(&self) -> AttachMode {
929 self.mode
930 }
931
932 fn input_owner(&self) -> Option<ClientId> {
933 self.input_owner
934 }
935
936 fn roster(&self) -> Roster {
937 self.roster.clone()
938 }
939
940 fn client_input(&self) -> ClientInput {
941 self.client_input
942 }
943
944 async fn reconnect(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
951 let was_owner = self.input_owner == Some(self.client);
952 let generation = self.reload_pending.unwrap_or(self.welcome.generation);
953 let mut hello = self.hello.clone();
954 hello.reconnect_of = Some(ClientReconnect {
955 previous_client: self.client,
956 session_id: self.session.clone(),
957 was_owner,
958 generation,
959 });
960 let mode = if was_owner { AttachMode::Takeover } else { mode };
961 let total = std::env::var("SYNAPS_TUI_ATTACH_RECONNECT_SECS")
962 .ok()
963 .and_then(|s| s.parse().ok())
964 .map(Duration::from_secs)
965 .unwrap_or(Duration::from_secs(60));
966 let deadline = tokio::time::Instant::now() + total;
967 let mut backoff = Duration::from_millis(100);
968 let mut last;
969 loop {
970 match Connected::connect(&self.path, hello.clone()).await {
971 Ok(conn) => {
972 match Self::attach(conn, Attach::Existing { session_id: self.session.clone(), mode }).await {
973 Ok((t, snap)) => {
974 *self = t;
975 return Ok(snap);
976 }
977 Err(TransportError::Refused(m)) => return Err(TransportError::Refused(m)),
978 Err(e) => last = e,
979 }
980 }
981 Err(TransportError::Version { client, daemon }) => {
982 return Err(TransportError::Version { client, daemon })
983 }
984 Err(e) => last = e,
985 }
986 if tokio::time::Instant::now() + backoff > deadline {
987 return Err(last);
988 }
989 tokio::time::sleep(backoff).await;
990 backoff = (backoff * 2).min(Duration::from_secs(5));
991 }
992 }
993
994 async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
995 SocketTransport::reconnect_once(self, mode).await
997 }
998
999 fn is_reload_pending(&self) -> bool {
1000 SocketTransport::is_reload_pending(self)
1001 }
1002
1003 fn daemon_pid(&self) -> u32 {
1004 SocketTransport::daemon_pid(self)
1005 }
1006
1007 fn daemon_binary(&self) -> Option<crate::session::transport::BinaryId> {
1008 Some(crate::session::transport::BinaryId::of(&self.welcome))
1009 }
1010
1011 fn socket_path(&self) -> Option<&Path> {
1012 Some(&self.path)
1013 }
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018 use super::*;
1019 use crate::ipc::Listener;
1020 use crate::{LlmEvent, StreamEvent};
1021
1022 async fn fake_daemon(mut listener: Listener, protocol_version: u32) {
1025 let stream = listener.accept().await.unwrap();
1026 let (r, mut w) = crate::ipc::split_server(stream);
1027 let mut reader = BufReader::new(r);
1028 let mut line = String::new();
1029 let mut seq = 0u64;
1030 let sid = SessionId::from("fake-1");
1031 loop {
1032 line.clear();
1033 if reader.read_line(&mut line).await.unwrap() == 0 {
1034 break;
1035 }
1036 let frame: ClientFrame = decode_line(line.trim_end()).unwrap();
1037 let reply = match frame {
1038 ClientFrame::Hello(h) => {
1039 if h.protocol_version != protocol_version {
1040 let f = DaemonFrame::Refused {
1041 reason: RefuseReason::Version { daemon_version: protocol_version, min: protocol_version, max: protocol_version },
1042 message: "version".into(),
1043 };
1044 w.write_all(encode_line(&f).unwrap().as_bytes()).await.unwrap();
1045 break;
1046 }
1047 DaemonFrame::Welcome(Welcome {
1048 protocol_version,
1049 daemon_version: "t".into(),
1050 pid: 1,
1051 profile: None,
1052 sessions: vec![],
1053 progressive_tool_disclosure: true,
1054 generation: 1,
1055 daemon_build: String::new(),
1056 })
1057 }
1058 ClientFrame::Ping => DaemonFrame::Pong { pid: 1, uptime_s: 0, sessions: 0 },
1059 ClientFrame::Attach(_) => DaemonFrame::Attached(AttachedWire::new(
1060 ClientId(7),
1061 AttachSnapshot {
1062 meta: crate::session::handle::echo::meta_for(&sid),
1063 view: crate::session::handle::echo::view_for(),
1064 conversation: ConversationSnapshot::default(),
1065 streaming: false,
1066 replay: vec![],
1067 pending_prompts: vec![],
1068 clients: vec![],
1069 roster: Vec::new(),
1070 client_input: ClientInput::wire_default(),
1071 input_owner: Some(ClientId(7)),
1072 display_tail: None,
1073 },
1074 )),
1075 ClientFrame::Cmd { cmd: SessionCommand::Submit { text, .. }, .. } => {
1076 seq += 1;
1077 DaemonFrame::Event(WireEnvelope {
1078 session_id: sid.clone(),
1079 seq,
1080 ts: chrono::Utc::now(),
1081 event: SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(text))).into(),
1082 })
1083 }
1084 ClientFrame::Cmd { cmd: SessionCommand::Set { id, .. }, .. } => {
1085 seq += 1;
1086 let mut view = crate::session::handle::echo::view_for();
1087 view.model = "changed".into();
1088 DaemonFrame::Event(WireEnvelope {
1089 session_id: sid.clone(),
1090 seq,
1091 ts: chrono::Utc::now(),
1092 event: SessionEventWire::SettingChanged(SettingApplied {
1093 id,
1094 setting: "model".into(),
1095 ok: true,
1096 message: None,
1097 view,
1098 clamp: None,
1099 })
1100 .into(),
1101 })
1102 }
1103 ClientFrame::Cmd { cmd: SessionCommand::Checkpoint { .. }, .. } => {
1105 seq += 1;
1106 let ev = DaemonFrame::Event(WireEnvelope {
1107 session_id: sid.clone(),
1108 seq,
1109 ts: chrono::Utc::now(),
1110 event: WireSessionEvent::Reloading { generation: 2, retry_after_ms: 500 },
1111 });
1112 w.write_all(encode_line(&ev).unwrap().as_bytes()).await.unwrap();
1113 let bye = DaemonFrame::Bye { reason: Some(ByeReason::Reloading { generation: 2, retry_after_ms: 500 }) };
1114 w.write_all(encode_line(&bye).unwrap().as_bytes()).await.unwrap();
1115 break;
1116 }
1117 ClientFrame::Cmd { cmd: SessionCommand::KeepWarm { .. }, .. } => {
1119 seq += 1;
1120 DaemonFrame::Event(WireEnvelope {
1121 session_id: sid.clone(),
1122 seq,
1123 ts: chrono::Utc::now(),
1124 event: WireSessionEvent::InputOwnerChanged {
1125 from: Some(ClientId(7)),
1126 to: Some(ClientId(9)),
1127 reason: OwnerChangeReason::Takeover,
1128 },
1129 })
1130 }
1131 ClientFrame::Cmd { cmd: SessionCommand::Steer { text }, .. } => {
1133 seq += 1;
1134 let snap = ConversationSnapshot {
1135 api_messages: vec![Arc::new(serde_json::json!({"role":"user","content":text}))],
1136 cost: 1.5,
1137 ..Default::default()
1138 };
1139 DaemonFrame::Event(WireEnvelope {
1140 session_id: sid.clone(),
1141 seq,
1142 ts: chrono::Utc::now(),
1143 event: SessionEventWire::Conversation(snap).into(),
1144 })
1145 }
1146 ClientFrame::Cmd { cmd: SessionCommand::Query { id, query: SessionQuery::Messages }, .. } => {
1147 seq += 1;
1148 DaemonFrame::Event(WireEnvelope {
1149 session_id: sid.clone(),
1150 seq,
1151 ts: chrono::Utc::now(),
1152 event: WireSessionEvent::QueryResult {
1153 id,
1154 value: serde_json::json!([{"role":"user","content":"drifted"}]),
1155 },
1156 })
1157 }
1158 ClientFrame::Bye => {
1159 let _ = w.write_all(encode_line(&DaemonFrame::Bye { reason: None }).unwrap().as_bytes()).await;
1160 break;
1161 }
1162 ClientFrame::Cmd { cmd: SessionCommand::Detach { .. }, .. } => continue,
1163 _ => DaemonFrame::Error { session_id: None, message: "nope".into() },
1164 };
1165 let bytes = encode_line(&reply).unwrap();
1166 if bytes.contains("split:") {
1169 let (a, b) = bytes.as_bytes().split_at(bytes.len() / 2);
1170 w.write_all(a).await.unwrap();
1171 w.flush().await.unwrap();
1172 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1173 w.write_all(b).await.unwrap();
1174 continue;
1175 }
1176 w.write_all(bytes.as_bytes()).await.unwrap();
1177 }
1178 }
1179
1180 fn sock() -> (tempfile::TempDir, std::path::PathBuf) {
1181 let d = tempfile::tempdir().unwrap();
1182 let p = d.path().join("d.sock");
1183 (d, p)
1184 }
1185
1186 #[test]
1191 fn predates_auth_state_matches_an_old_daemons_decode_error_and_nothing_else() {
1192 #[derive(serde::Deserialize)]
1193 #[serde(tag = "type", rename_all = "snake_case")]
1194 #[allow(dead_code, clippy::large_enum_variant)]
1195 enum OldClientFrame {
1196 Hello(Hello),
1197 Ping,
1198 Sessions,
1199 Shutdown {
1200 #[serde(default)]
1201 force: bool,
1202 },
1203 Purge,
1204 Bye,
1205 }
1206 for query in [
1207 agent_core::auth::AuthStateQuery::default(),
1208 agent_core::auth::AuthStateQuery { provider: Some("anthropic".into()), account: Some("b".into()), clear: true },
1209 ] {
1210 let line = encode_line(&ClientFrame::AuthState(query)).unwrap();
1211 let err = decode_line::<OldClientFrame>(line.trim_end()).err().expect("an old daemon cannot decode it");
1212 let reply = format!("malformed frame: {err}");
1213 assert!(predates_auth_state(&reply), "{reply}");
1214 }
1215 for other in [
1216 "auth state: unknown account `zzz`",
1217 "malformed frame: expected value at line 1 column 1",
1218 "malformed frame: unknown variant `auth_status`, expected one of `hello`, `ping`",
1219 "malformed frame: missing field `clear`",
1220 "control frames are not accepted on an attached connection",
1221 "duplicate hello",
1222 "",
1223 ] {
1224 assert!(!predates_auth_state(other), "{other:?}");
1225 }
1226 }
1227
1228 #[tokio::test]
1232 async fn next_event_survives_cancellation_mid_frame() {
1233 let (_d, path) = sock();
1234 let l = Listener::bind(&path).unwrap();
1235 let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1236 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1237 let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1238 .await
1239 .unwrap();
1240 t.send(SessionCommand::Submit { text: "split:hello".into(), attachments: vec![] }).await.unwrap();
1241 tokio::select! {
1243 e = t.next_event() => panic!("frame cannot be complete yet: {e:?}"),
1244 _ = tokio::time::sleep(std::time::Duration::from_millis(20)) => {}
1245 }
1246 let e = tokio::time::timeout(std::time::Duration::from_secs(2), t.next_event())
1247 .await
1248 .expect("no event");
1249 match e.map(|e| e.event) {
1250 Some(SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(s)))) => assert_eq!(s, "split:hello"),
1251 other => panic!("frame lost to cancellation: {other:?} (last_error={:?})", t.last_error()),
1252 }
1253 t.detach().await;
1254 let _ = srv.await;
1255 }
1256
1257 #[tokio::test]
1258 async fn socket_transport_handshake_attach_pump() {
1259 let (_d, path) = sock();
1260 let l = Listener::bind(&path).unwrap();
1261 let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1262
1263 let mut conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1264 assert_eq!(conn.welcome.protocol_version, PROTOCOL_VERSION);
1265 assert_eq!(conn.ping().await.unwrap().pid, 1);
1266 let (mut t, snap) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1267 .await
1268 .unwrap();
1269 assert_eq!(t.client_id(), ClientId(7));
1270 assert_eq!(t.mode(), AttachMode::Mirror);
1271 assert_eq!(t.input_owner(), Some(ClientId(7)));
1272 assert_eq!(snap.meta.id.as_str(), "fake-1");
1273 assert_eq!(t.session_id().as_str(), "fake-1");
1274 assert_eq!(t.view().model, "echo");
1275
1276 t.send(SessionCommand::Submit { text: "hello".into(), attachments: vec![] }).await.unwrap();
1277 let e = t.next_event().await.unwrap();
1278 match e.event {
1279 SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(s))) => assert_eq!(s, "hello"),
1280 other => panic!("{other:?}"),
1281 }
1282 t.send_from_self(SessionCommand::Set { id: 11, setting: SessionSetting::Model { model: "changed".into() } })
1283 .await
1284 .unwrap();
1285 let e = t.next_event().await.unwrap();
1286 assert!(matches!(e.event, SessionEventWire::SettingChanged(SettingApplied { id: 11, .. })));
1287 assert_eq!(t.view().model, "changed");
1288 t.send(SessionCommand::KeepWarm { on: true }).await.unwrap();
1289 let e = t.next_event().await.unwrap();
1290 assert!(matches!(e.event, SessionEventWire::InputOwnerChanged { .. }));
1291 assert_eq!(t.input_owner(), Some(ClientId(9)));
1292
1293 t.send(SessionCommand::Save).await.unwrap();
1295 assert!(matches!(t.next_event().await.unwrap().event, SessionEventWire::SystemNotice(_)));
1296 t.detach().await;
1297 assert!(t.next_event().await.is_none());
1298 assert!(t.next_event().await.is_none());
1299 srv.await.unwrap();
1300 }
1301
1302 #[tokio::test]
1303 async fn conversation_digest_miss_requeries_messages() {
1304 let (_d, path) = sock();
1305 let l = Listener::bind(&path).unwrap();
1306 let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1307 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1308 let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1309 .await
1310 .unwrap();
1311 assert!(t.messages().is_empty());
1312 t.send(SessionCommand::Steer { text: "drifted".into() }).await.unwrap();
1313 let e = t.next_event().await.unwrap();
1316 match e.event {
1317 SessionEventWire::Conversation(c) => {
1318 assert_eq!(c.api_messages.len(), 1);
1319 assert_eq!(c.api_messages[0]["content"], "drifted");
1320 assert_eq!(c.cost, 1.5);
1321 }
1322 other => panic!("{other:?}"),
1323 }
1324 assert_eq!(t.messages().len(), 1);
1325 t.send(SessionCommand::Steer { text: "drifted".into() }).await.unwrap();
1327 let e = t.next_event().await.unwrap();
1328 assert!(matches!(e.event, SessionEventWire::Conversation(ref c) if c.api_messages.len() == 1));
1329 t.detach().await;
1330 assert!(t.next_event().await.is_none());
1331 srv.await.unwrap();
1332 }
1333
1334 #[tokio::test]
1335 async fn reloading_then_bye_sets_last_error_and_reconnect_gives_up_on_a_dead_socket() {
1336 let (_d, path) = sock();
1337 let l = Listener::bind(&path).unwrap();
1338 let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1339 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1340 let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Observe })
1341 .await
1342 .unwrap();
1343 assert_eq!(t.mode(), AttachMode::Observe);
1344 assert!(t.last_error().is_none());
1345 t.send(SessionCommand::Checkpoint { reason: CheckpointReason::Reload }).await.unwrap();
1346 let e = t.next_event().await.unwrap();
1347 assert!(matches!(e.event, SessionEventWire::Reloading { generation: 2, .. }));
1348 assert!(t.next_event().await.is_none());
1349 assert!(matches!(t.last_error(), Some(TransportError::Reloading { generation: 2 })));
1350 srv.await.unwrap();
1351 std::env::set_var("SYNAPS_TUI_ATTACH_RECONNECT_SECS", "1");
1353 let r = t.reconnect(AttachMode::Observe).await;
1354 std::env::remove_var("SYNAPS_TUI_ATTACH_RECONNECT_SECS");
1355 assert!(r.is_err());
1356 }
1357
1358 #[tokio::test]
1359 async fn attach_error_frame_is_refused() {
1360 let (_d, path) = sock();
1361 let mut l = Listener::bind(&path).unwrap();
1362 let srv = tokio::spawn(async move {
1364 let stream = l.accept().await.unwrap();
1365 let (r, mut w) = crate::ipc::split_server(stream);
1366 let mut reader = BufReader::new(r);
1367 let mut line = String::new();
1368 reader.read_line(&mut line).await.unwrap();
1369 let welcome = DaemonFrame::Welcome(Welcome {
1370 protocol_version: PROTOCOL_VERSION,
1371 daemon_version: "t".into(),
1372 pid: 1,
1373 profile: None,
1374 sessions: vec![],
1375 progressive_tool_disclosure: true,
1376 generation: 1,
1377 daemon_build: String::new(),
1378 });
1379 w.write_all(encode_line(&welcome).unwrap().as_bytes()).await.unwrap();
1380 line.clear();
1381 reader.read_line(&mut line).await.unwrap();
1382 let err = DaemonFrame::Error { session_id: None, message: "unknown session".into() };
1383 w.write_all(encode_line(&err).unwrap().as_bytes()).await.unwrap();
1384 });
1385 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1386 let err = SocketTransport::attach(conn, Attach::Existing { session_id: "nope".into(), mode: AttachMode::Mirror })
1387 .await
1388 .err()
1389 .unwrap();
1390 assert!(matches!(err, TransportError::Refused(ref m) if m == "unknown session"), "{err}");
1391 srv.await.unwrap();
1392 }
1393
1394 #[tokio::test]
1395 async fn socket_transport_refuses_version_mismatch() {
1396 let (_d, path) = sock();
1397 let l = Listener::bind(&path).unwrap();
1398 let srv = tokio::spawn(fake_daemon(l, 99));
1399 let err = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.err().unwrap();
1400 assert!(matches!(err, TransportError::Version { client: PROTOCOL_VERSION, daemon: 99 }), "{err}");
1401 srv.await.unwrap();
1402 }
1403
1404 #[test]
1407 fn close_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1408 let old = DaemonFrame::Error {
1409 session_id: None,
1410 message: "malformed frame: unknown variant `close`, expected one of `hello`, `ping`".into(),
1411 };
1412 let err = close_reply(Some(old), "0.9.0").unwrap_err();
1413 assert!(matches!(err, TransportError::DaemonTooOld { ref daemon_version, .. } if daemon_version == "0.9.0"), "{err}");
1414 assert!(err.to_string().contains("synaps daemon reload"), "{err}");
1415
1416 let closed = DaemonFrame::Closed { session_id: "s".into(), text: "closing session s".into() };
1417 assert_eq!(close_reply(Some(closed), "1").unwrap(), "closing session s");
1418 let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "not closed: a turn is running".into() };
1419 assert!(matches!(close_reply(Some(busy), "1"), Err(TransportError::Refused(ref m)) if m.contains("turn")));
1420 let unknown = DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message: "no live session s".into() };
1421 assert!(matches!(close_reply(Some(unknown), "1"), Err(TransportError::UnknownSession(_))));
1422 let other = DaemonFrame::Error { session_id: None, message: "close: no reply".into() };
1423 assert!(matches!(close_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1424 assert!(matches!(close_reply(None, "1"), Err(TransportError::Closed)));
1425 }
1426
1427 #[test]
1428 fn pin_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1429 let old = DaemonFrame::Error {
1430 session_id: None,
1431 message: "malformed frame: unknown variant `pin`, expected one of `hello`, `ping`".into(),
1432 };
1433 let err = pin_reply(Some(old), "0.9.0").unwrap_err();
1434 assert!(
1435 matches!(err, TransportError::DaemonTooOld { ref daemon_version, request: "daemon pin" } if daemon_version == "0.9.0"),
1436 "{err}"
1437 );
1438 assert!(err.to_string().contains("synaps daemon reload"), "{err}");
1439
1440 assert!(pin_reply(Some(DaemonFrame::Pinned { session_id: "s".into(), on: true }), "1").unwrap());
1441 assert!(!pin_reply(Some(DaemonFrame::Pinned { session_id: "s".into(), on: false }), "1").unwrap());
1442 let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading".into() };
1443 assert!(matches!(pin_reply(Some(busy), "1"), Err(TransportError::Refused(ref m)) if m.contains("reloading")));
1444 let unknown = DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message: "no live session s".into() };
1445 assert!(matches!(pin_reply(Some(unknown), "1"), Err(TransportError::UnknownSession(_))));
1446 let other = DaemonFrame::Error { session_id: None, message: "pin: timed out".into() };
1447 assert!(matches!(pin_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1448 let closed = DaemonFrame::Closed { session_id: "s".into(), text: "x".into() };
1449 assert!(matches!(pin_reply(Some(closed), "1"), Err(TransportError::Protocol(_))));
1450 assert!(matches!(pin_reply(None, "1"), Err(TransportError::Closed)));
1451 }
1452
1453 #[test]
1454 fn rename_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1455 use crate::session::wire::RenameRefusal;
1456 let old = DaemonFrame::Error {
1457 session_id: None,
1458 message: "malformed frame: unknown variant `rename`, expected one of `hello`, `ping`".into(),
1459 };
1460 let err = rename_reply(Some(old), "0.9.0").unwrap_err();
1461 assert!(
1462 matches!(err, TransportError::DaemonTooOld { ref daemon_version, request: "daemon rename" } if daemon_version == "0.9.0"),
1463 "{err}"
1464 );
1465 assert!(err.to_string().contains("predates `daemon rename`"), "{err}");
1466
1467 let ok = DaemonFrame::Renamed { session_id: "s".into(), name: Some("axon".into()), in_memory_only: false };
1468 assert_eq!(rename_reply(Some(ok), "1").unwrap(), Ok(Renamed { name: Some("axon".into()), in_memory_only: false }));
1469 let cleared = DaemonFrame::Renamed { session_id: "s".into(), name: None, in_memory_only: true };
1470 assert_eq!(rename_reply(Some(cleared), "1").unwrap(), Ok(Renamed { name: None, in_memory_only: true }));
1471 let taken = DaemonFrame::RenameFailed {
1472 session_id: "s".into(),
1473 reason: RenameRefusal::Taken,
1474 message: "name 'axon' already used by session t".into(),
1475 taken_by: Some("t".into()),
1476 current_name: None,
1477 };
1478 let failure = rename_reply(Some(taken), "1").unwrap().unwrap_err();
1479 assert_eq!((failure.reason, failure.taken_by.as_deref()), (RenameRefusal::Taken, Some("t")));
1480 let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading".into() };
1481 assert!(matches!(rename_reply(Some(busy), "1"), Err(TransportError::Refused(_))));
1482 let unknown = DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message: "no live session s".into() };
1483 assert!(matches!(rename_reply(Some(unknown), "1"), Err(TransportError::UnknownSession(_))));
1484 let other = DaemonFrame::Error { session_id: None, message: "rename: no reply".into() };
1485 assert!(matches!(rename_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1486 assert!(matches!(rename_reply(None, "1"), Err(TransportError::Closed)));
1487 }
1488
1489 #[test]
1490 fn reload_extension_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1491 let old = DaemonFrame::Error {
1492 session_id: None,
1493 message: "malformed frame: unknown variant `reload_extension`, expected one of `hello`, `ping`".into(),
1494 };
1495 let err = reload_extension_reply(Some(old), "0.9.0").unwrap_err();
1496 assert!(
1497 matches!(err, TransportError::DaemonTooOld { ref daemon_version, request: "daemon reload-extension" } if daemon_version == "0.9.0"),
1498 "{err}"
1499 );
1500
1501 let report = ExtensionReloadReport {
1502 extension: "axel".into(),
1503 was_loaded: true,
1504 deferred: false,
1505 tools: vec!["axel:axel_search".into()],
1506 removed_tools: vec![],
1507 plugin_dir: std::path::PathBuf::from("/p/axel"),
1508 };
1509 let ok = DaemonFrame::ExtensionReloaded { report: report.clone() };
1510 assert_eq!(reload_extension_reply(Some(ok), "1").unwrap(), Ok(report));
1511 let failed = DaemonFrame::ExtensionReloadFailed { extension: "axel".into(), message: "spawn failed".into(), unloaded: true };
1512 assert_eq!(
1513 reload_extension_reply(Some(failed), "1").unwrap(),
1514 Err(ExtensionReloadFailure { message: "spawn failed".into(), unloaded: true })
1515 );
1516 let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading".into() };
1517 assert!(matches!(reload_extension_reply(Some(busy), "1"), Err(TransportError::Refused(ref m)) if m.contains("reloading")));
1518 let other = DaemonFrame::Error { session_id: None, message: "frame too large".into() };
1520 assert!(matches!(reload_extension_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1521 let pinned = DaemonFrame::Pinned { session_id: "s".into(), on: true };
1522 assert!(matches!(reload_extension_reply(Some(pinned), "1"), Err(TransportError::Protocol(_))));
1523 assert!(matches!(reload_extension_reply(None, "1"), Err(TransportError::Closed)));
1524 }
1525
1526 #[tokio::test]
1527 async fn socket_transport_connect_refused_when_nobody_listens() {
1528 let (_d, path) = sock();
1529 let err = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.err().unwrap();
1530 assert!(matches!(err, TransportError::Io(_)));
1531 }
1532}