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 owner: Option<OwnerClaim>,
510}
511
512impl SocketTransport {
513 pub async fn connect(path: &Path, hello: Hello) -> Result<Connected, TransportError> {
515 Connected::connect(path, hello).await
516 }
517
518 pub async fn attach(mut conn: Connected, attach: Attach) -> Result<(Self, AttachSnapshot), TransportError> {
522 let (mode, parked) = match &attach {
523 Attach::Existing { session_id, mode } => (
524 *mode,
525 conn.welcome
526 .sessions
527 .iter()
528 .any(|m| &m.id == session_id && m.lifecycle == SessionLifecycle::Parked),
529 ),
530 Attach::Create { mode, .. } => (*mode, false),
531 };
532 let owned_create = match &attach {
535 Attach::Create { owned: Some(o), .. } => Some(o.forward_secrets.clone()),
536 _ => None,
537 };
538 if owned_create.is_some() && !conn.welcome.supports(FEATURE_OWNED_SESSIONS) {
539 let daemon_version = conn.welcome.daemon_version.clone();
540 conn.bye().await;
541 return Err(TransportError::DaemonTooOld { daemon_version, request: "owned sessions" });
542 }
543 let rebind = match &attach {
545 Attach::Existing { .. } => conn.hello.reconnect_of.as_ref().and_then(|r| r.owner.clone()),
546 Attach::Create { .. } => None,
547 };
548 conn.writer
549 .send(ClientFrame::Attach(attach))
550 .await
551 .map_err(|_| TransportError::Closed)?;
552 let budget = if parked { ATTACH_TIMEOUT_PARKED } else { ATTACH_TIMEOUT };
553 let deadline = tokio::time::Instant::now() + budget;
554 let attached = loop {
555 let frame = tokio::time::timeout_at(deadline, read_frame(&mut conn.reader, &mut conn.buf))
556 .await
557 .map_err(|_| TransportError::Protocol("attach timed out".into()))??;
558 match frame {
559 Some(DaemonFrame::Attached(a)) => break a,
560 Some(DaemonFrame::Error { message, .. }) => return Err(TransportError::Refused(message)),
561 Some(DaemonFrame::Event(w)) => match w.event {
562 WireSessionEvent::AttachRefused { message } => return Err(TransportError::Refused(message)),
563 _ => continue,
565 },
566 Some(DaemonFrame::Bye { .. }) | None => return Err(TransportError::Closed),
567 Some(other) => return Err(TransportError::Protocol(format!("expected attached, got {other:?}"))),
568 }
569 };
570 let owner = match (owned_create, rebind) {
571 (None, None) => None,
572 (Some(forward_secrets), _) => match attached.owner_token.clone() {
573 Some(token) => Some(OwnerClaim { token, forward_secrets }),
574 None => return Err(TransportError::Refused("daemon did not grant an owned session".into())),
575 },
576 (None, Some(claim)) if attached.owner_token.as_ref() == Some(&claim.token) => Some(claim),
577 (None, Some(_)) => return Err(TransportError::Refused("daemon did not re-bind the owned session".into())),
578 };
579 let (client, snapshot) = attached.into_snapshot();
580 let meta = snapshot.meta.clone();
581 let view = Arc::new(arc_swap::ArcSwap::from_pointee(snapshot.view.clone()));
582 let history = conn.hello.client.history;
583 let messages = match history {
584 HistoryMode::Full => snapshot.conversation.api_messages.clone(),
585 HistoryMode::Digest => Vec::new(),
586 };
587 Ok((
588 Self {
589 reader: conn.reader,
590 writer: conn.writer,
591 _writer_task: conn.writer_task,
592 session: meta.id.clone(),
593 meta,
594 client,
595 view,
596 welcome: conn.welcome,
597 mode,
598 input_owner: snapshot.input_owner,
599 roster: Roster::from_snapshot(&snapshot),
600 client_input: snapshot.client_input,
601 ended: false,
602 path: conn.path,
603 hello: conn.hello,
604 reload_pending: None,
605 last_error: None,
606 messages,
607 pending_digest: None,
608 history,
609 buf: conn.buf,
610 owner,
611 },
612 snapshot,
613 ))
614 }
615
616 pub fn is_session_owner(&self) -> bool {
619 self.owner.is_some()
620 }
621
622 pub async fn create_owned(
630 path: &Path,
631 hello: Hello,
632 config: SessionConfig,
633 mode: AttachMode,
634 forward_secrets: bool,
635 ) -> Result<(Self, AttachSnapshot), TransportError> {
636 let conn = Connected::connect(path, hello).await?;
637 Self::attach(conn, Attach::create_owned(config, mode, forward_secrets)).await
638 }
639
640 pub fn last_error(&self) -> Option<&TransportError> {
642 self.last_error.as_ref()
643 }
644
645 fn owner_lagged(&self) -> Option<u64> {
648 match self.last_error {
649 Some(TransportError::OwnerLagged { dropped }) => Some(dropped),
650 _ => None,
651 }
652 }
653
654 fn note_reload_end(&mut self) {
657 if self.owner_lagged().is_some() {
658 return;
659 }
660 if let Some(generation) = self.reload_pending {
661 self.last_error = Some(TransportError::Reloading { generation });
662 }
663 }
664
665 pub fn is_reload_pending(&self) -> bool {
668 self.reload_pending.is_some()
669 }
670
671 pub fn daemon_pid(&self) -> u32 {
673 self.welcome.pid
674 }
675
676 pub fn reconnect_budget() -> Duration {
678 std::env::var("SYNAPS_TUI_ATTACH_RECONNECT_SECS")
679 .ok()
680 .and_then(|s| s.parse().ok())
681 .map(Duration::from_secs)
682 .unwrap_or(Duration::from_secs(60))
683 }
684
685 pub async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
689 if let Some(dropped) = self.owner_lagged() {
690 return Err(TransportError::OwnerLagged { dropped });
691 }
692 let was_owner = self.input_owner == Some(self.client);
693 let generation = self.reload_pending.unwrap_or(self.welcome.generation);
694 let mut hello = self.hello.clone();
695 hello.reconnect_of = Some(ClientReconnect {
696 previous_client: self.client,
697 session_id: self.session.clone(),
698 was_owner,
699 generation,
700 owner: self.owner.clone(),
701 });
702 let mode = if was_owner { AttachMode::Takeover } else { mode };
703 match Connected::connect(&self.path, hello).await {
704 Ok(conn) => {
705 match Self::attach(conn, Attach::Existing { session_id: self.session.clone(), mode }).await {
706 Ok((t, snap)) => {
707 *self = t;
708 Ok(snap)
709 }
710 Err(e) => Err(e),
711 }
712 }
713 Err(e) => Err(e),
714 }
715 }
716
717 pub async fn ping(path: &Path) -> Result<Pong, TransportError> {
720 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
721 let p = c.ping().await;
722 c.bye().await;
723 p
724 }
725
726 pub async fn sessions(path: &Path) -> Result<Vec<SessionMeta>, TransportError> {
727 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
728 let s = c.sessions().await;
729 c.bye().await;
730 s
731 }
732
733 pub async fn shutdown(path: &Path, force: bool) -> Result<(), TransportError> {
734 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
735 c.shutdown(force).await
736 }
737
738 pub async fn purge(path: &Path) -> Result<Pong, TransportError> {
739 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
740 let p = c.purge().await;
741 c.bye().await;
742 p
743 }
744
745 pub async fn close(path: &Path, id: &SessionId, force: bool, attached: bool) -> Result<String, TransportError> {
748 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
749 let r = c.close(id, force, attached).await;
750 c.bye().await;
751 r
752 }
753
754 pub async fn pin(path: &Path, id: &SessionId, on: bool) -> Result<bool, TransportError> {
756 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
757 let r = c.pin(id, on).await;
758 c.bye().await;
759 r
760 }
761
762 pub async fn rename(
765 path: &Path,
766 id: &SessionId,
767 name: Option<&str>,
768 expect: Option<Option<String>>,
769 ) -> Result<Result<Renamed, RenameFailure>, TransportError> {
770 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
771 let r = c.rename(id, name, expect).await;
772 c.bye().await;
773 r
774 }
775
776 pub async fn auth_state(path: &Path, query: agent_core::auth::AuthStateQuery) -> Result<AuthStateReply, TransportError> {
778 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
779 let reply = c.auth_state(query).await;
780 c.bye().await;
781 reply
782 }
783
784 pub async fn reload(path: &Path, now: bool, drain_secs: Option<u64>, exe: Option<PathBuf>) -> Result<u64, TransportError> {
788 let mut c = Connected::connect(path, Hello::new(ClientKind::Attach)).await?;
789 c.writer
790 .send(ClientFrame::Reload { now, drain_secs, exe })
791 .await
792 .map_err(|_| TransportError::Closed)?;
793 let budget = Duration::from_secs(drain_secs.unwrap_or(30) + 60);
795 let frame = tokio::time::timeout(budget, read_frame(&mut c.reader, &mut c.buf))
796 .await
797 .map_err(|_| TransportError::Protocol("reload reply timed out".into()))??;
798 match frame {
799 Some(DaemonFrame::Bye { reason: Some(ByeReason::Reloading { generation, .. }) }) => Ok(generation),
800 Some(DaemonFrame::Bye { .. }) => Err(TransportError::Protocol("daemon said bye without a reload reason".into())),
801 Some(DaemonFrame::Refused { reason: RefuseReason::ReloadRefused { why }, .. }) => Err(TransportError::Refused(why)),
802 Some(DaemonFrame::Refused { message, .. }) | Some(DaemonFrame::Error { message, .. }) => Err(TransportError::Refused(message)),
803 Some(other) => Err(TransportError::Protocol(format!("expected bye, got {other:?}"))),
804 None => Err(TransportError::Closed),
805 }
806 }
807
808 pub async fn detach(&self) {
810 let _ = self
811 .writer
812 .send(ClientFrame::Cmd { session_id: self.session.clone(), cmd: SessionCommand::Detach { client: self.client } })
813 .await;
814 let _ = self.writer.send(ClientFrame::Bye).await;
815 }
816
817 fn notice(&self, text: String) -> Envelope {
818 Envelope { session_id: self.session.clone(), seq: u64::MAX, ts: chrono::Utc::now(), event: SessionEventWire::SystemNotice(text) }
819 }
820
821 pub fn messages(&self) -> &[crate::SharedMessage] {
824 &self.messages
825 }
826
827 pub fn last_frame_bytes(&self) -> usize {
830 self.buf.last_frame_bytes
831 }
832
833 pub fn history_mode(&self) -> HistoryMode {
834 self.history
835 }
836
837 fn absorb(&mut self, w: WireEnvelope) -> Option<Envelope> {
842 if self.history == HistoryMode::Digest {
843 return self.absorb_digest(w);
844 }
845 match w.event {
846 WireSessionEvent::Conversation { digest } => {
847 if digest.matches(&self.messages) {
848 let event = SessionEventWire::Conversation(digest.into_snapshot(self.messages.clone()));
849 return Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event });
850 }
851 if digest.messages_len == 0 {
852 self.messages.clear();
853 let event = SessionEventWire::Conversation(digest.into_snapshot(Vec::new()));
854 return Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event });
855 }
856 let first = self.pending_digest.is_none();
858 self.pending_digest = Some(digest);
859 if first {
860 let _ = self.writer.try_send(ClientFrame::Cmd {
861 session_id: self.session.clone(),
862 cmd: SessionCommand::Query { id: DIGEST_RESYNC_QUERY_ID, query: SessionQuery::Messages },
863 });
864 }
865 None
866 }
867 WireSessionEvent::QueryResult { id, value } if id == DIGEST_RESYNC_QUERY_ID => {
868 if let Ok(msgs) = serde_json::from_value::<Vec<serde_json::Value>>(value) {
869 self.messages = msgs.into_iter().map(Arc::new).collect();
870 }
871 let digest = self.pending_digest.take()?;
872 if !digest.matches(&self.messages) {
873 tracing::debug!("socket_transport: messages re-query still disagrees with digest; using fetched history");
874 }
875 let event = SessionEventWire::Conversation(digest.into_snapshot(self.messages.clone()));
876 Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event })
877 }
878 WireSessionEvent::QueryResult { id, .. } if id >= RESERVED_QUERY_ID_BASE => None,
879 event => {
880 let env = Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event: event.into() };
881 match &env.event {
882 SessionEventWire::Stream(crate::StreamEvent::Session(crate::SessionEvent::MessageHistory(m))) => {
883 self.messages = m.clone();
884 }
885 SessionEventWire::Attached { snapshot, .. } => {
886 self.messages = snapshot.conversation.api_messages.clone();
887 }
888 _ => {}
889 }
890 Some(env)
891 }
892 }
893 }
894}
895
896impl SocketTransport {
897 fn absorb_digest(&mut self, w: WireEnvelope) -> Option<Envelope> {
902 match w.event {
903 WireSessionEvent::Conversation { digest } => {
904 let event = SessionEventWire::Conversation(digest.into_snapshot(Vec::new()));
905 Some(Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event })
906 }
907 WireSessionEvent::QueryResult { id, .. } if id >= RESERVED_QUERY_ID_BASE => None,
908 event => {
909 let env = Envelope { session_id: w.session_id, seq: w.seq, ts: w.ts, event: event.into() };
910 if matches!(
911 env.event,
912 SessionEventWire::Stream(crate::StreamEvent::Session(crate::SessionEvent::MessageHistory(_)))
913 ) {
914 return None;
915 }
916 Some(env)
917 }
918 }
919 }
920}
921
922#[async_trait::async_trait]
923impl ClientTransport for SocketTransport {
924 fn session_id(&self) -> &SessionId {
925 &self.session
926 }
927
928 fn meta(&self) -> &SessionMeta {
929 &self.meta
930 }
931
932 async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError> {
933 match self.writer.try_send(ClientFrame::Cmd { session_id: self.session.clone(), cmd }) {
934 Ok(()) => Ok(()),
935 Err(mpsc::error::TrySendError::Full(_)) => Err(TransportError::Backpressure),
936 Err(mpsc::error::TrySendError::Closed(_)) => Err(TransportError::Closed),
937 }
938 }
939
940 async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError> {
943 self.send(cmd).await
944 }
945
946 async fn next_event(&mut self) -> Option<Envelope> {
947 if self.ended {
948 return None;
949 }
950 loop {
951 match read_frame(&mut self.reader, &mut self.buf).await {
952 Ok(Some(DaemonFrame::Event(w))) => {
953 let Some(env) = self.absorb(w) else { continue };
954 match &env.event {
955 SessionEventWire::SettingChanged(applied) => {
956 self.view.store(Arc::new(applied.view.clone()));
957 }
958 SessionEventWire::Ended { .. } => self.ended = true,
959 SessionEventWire::InputOwnerChanged { to, .. } => self.input_owner = *to,
960 SessionEventWire::Reloading { generation, .. } => {
961 self.reload_pending = Some(*generation);
962 }
963 other => self.roster.apply(other),
964 }
965 return Some(env);
966 }
967 Ok(Some(DaemonFrame::Error { message, .. })) => return Some(self.notice(message)),
968 Ok(Some(DaemonFrame::Bye { reason })) => {
969 match reason {
970 Some(ByeReason::Reloading { generation, .. }) => self.reload_pending = Some(generation),
971 Some(ByeReason::OwnerLagged { dropped }) => {
974 self.reload_pending = None;
975 self.last_error = Some(TransportError::OwnerLagged { dropped });
976 }
977 _ => {}
978 }
979 self.ended = true;
980 self.note_reload_end();
981 return None;
982 }
983 Ok(None) => {
984 self.ended = true;
985 self.note_reload_end();
986 return None;
987 }
988 Ok(Some(other)) => {
989 tracing::debug!("socket_transport: unexpected frame after attach: {other:?}");
990 continue;
991 }
992 Err(e) => {
993 tracing::debug!("socket_transport: read error: {e}");
994 self.ended = true;
995 return None;
996 }
997 }
998 }
999 }
1000
1001 fn view(&self) -> Arc<RuntimeView> {
1002 Arc::clone(&self.view.load())
1003 }
1004
1005 fn client_id(&self) -> ClientId {
1006 self.client
1007 }
1008
1009 fn mode(&self) -> AttachMode {
1010 self.mode
1011 }
1012
1013 fn input_owner(&self) -> Option<ClientId> {
1014 self.input_owner
1015 }
1016
1017 fn roster(&self) -> Roster {
1018 self.roster.clone()
1019 }
1020
1021 fn client_input(&self) -> ClientInput {
1022 self.client_input
1023 }
1024
1025 async fn reconnect(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
1032 if let Some(dropped) = self.owner_lagged() {
1033 return Err(TransportError::OwnerLagged { dropped });
1034 }
1035 let was_owner = self.input_owner == Some(self.client);
1036 let generation = self.reload_pending.unwrap_or(self.welcome.generation);
1037 let mut hello = self.hello.clone();
1038 hello.reconnect_of = Some(ClientReconnect {
1039 previous_client: self.client,
1040 session_id: self.session.clone(),
1041 was_owner,
1042 generation,
1043 owner: self.owner.clone(),
1044 });
1045 let mode = if was_owner { AttachMode::Takeover } else { mode };
1046 let total = Self::reconnect_budget();
1050 let deadline = tokio::time::Instant::now() + total;
1051 let mut backoff = Duration::from_millis(100);
1052 let mut last;
1053 loop {
1054 match Connected::connect(&self.path, hello.clone()).await {
1055 Ok(conn) => {
1056 match Self::attach(conn, Attach::Existing { session_id: self.session.clone(), mode }).await {
1057 Ok((t, snap)) => {
1058 *self = t;
1059 return Ok(snap);
1060 }
1061 Err(TransportError::Refused(m)) => return Err(TransportError::Refused(m)),
1062 Err(e) => last = e,
1063 }
1064 }
1065 Err(TransportError::Version { client, daemon }) => {
1066 return Err(TransportError::Version { client, daemon })
1067 }
1068 Err(e) => last = e,
1069 }
1070 if tokio::time::Instant::now() + backoff > deadline {
1071 return Err(last);
1072 }
1073 tokio::time::sleep(backoff).await;
1074 backoff = (backoff * 2).min(Duration::from_secs(5));
1075 }
1076 }
1077
1078 async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
1079 SocketTransport::reconnect_once(self, mode).await
1081 }
1082
1083 fn is_reload_pending(&self) -> bool {
1084 SocketTransport::is_reload_pending(self)
1085 }
1086
1087 fn daemon_pid(&self) -> u32 {
1088 SocketTransport::daemon_pid(self)
1089 }
1090
1091 fn daemon_binary(&self) -> Option<crate::session::transport::BinaryId> {
1092 Some(crate::session::transport::BinaryId::of(&self.welcome))
1093 }
1094
1095 fn socket_path(&self) -> Option<&Path> {
1096 Some(&self.path)
1097 }
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102 use super::*;
1103 use crate::ipc::Listener;
1104 use crate::{LlmEvent, StreamEvent};
1105
1106 async fn fake_daemon(mut listener: Listener, protocol_version: u32) {
1109 let stream = listener.accept().await.unwrap();
1110 let (r, mut w) = crate::ipc::split_server(stream);
1111 let mut reader = BufReader::new(r);
1112 let mut line = String::new();
1113 let mut seq = 0u64;
1114 let sid = SessionId::from("fake-1");
1115 loop {
1116 line.clear();
1117 if reader.read_line(&mut line).await.unwrap() == 0 {
1118 break;
1119 }
1120 let frame: ClientFrame = decode_line(line.trim_end()).unwrap();
1121 let reply = match frame {
1122 ClientFrame::Hello(h) => {
1123 if h.protocol_version != protocol_version {
1124 let f = DaemonFrame::Refused {
1125 reason: RefuseReason::Version { daemon_version: protocol_version, min: protocol_version, max: protocol_version },
1126 message: "version".into(),
1127 };
1128 w.write_all(encode_line(&f).unwrap().as_bytes()).await.unwrap();
1129 break;
1130 }
1131 DaemonFrame::Welcome(Welcome {
1132 protocol_version,
1133 daemon_version: "t".into(),
1134 pid: 1,
1135 profile: None,
1136 sessions: vec![],
1137 progressive_tool_disclosure: true,
1138 generation: 1,
1139 daemon_build: String::new(),
1140 features: Vec::new(),
1141 })
1142 }
1143 ClientFrame::Ping => DaemonFrame::Pong { pid: 1, uptime_s: 0, sessions: 0 },
1144 ClientFrame::Attach(_) => DaemonFrame::Attached(AttachedWire::new(
1145 ClientId(7),
1146 AttachSnapshot {
1147 meta: crate::session::handle::echo::meta_for(&sid),
1148 view: crate::session::handle::echo::view_for(),
1149 conversation: ConversationSnapshot::default(),
1150 streaming: false,
1151 replay: vec![],
1152 pending_prompts: vec![],
1153 clients: vec![],
1154 roster: Vec::new(),
1155 client_input: ClientInput::wire_default(),
1156 input_owner: Some(ClientId(7)),
1157 display_tail: None,
1158 },
1159 )),
1160 ClientFrame::Cmd { cmd: SessionCommand::Submit { text, .. }, .. } if text == "lag-after-reload" => {
1162 seq += 1;
1163 let ev = DaemonFrame::Event(WireEnvelope {
1164 session_id: sid.clone(),
1165 seq,
1166 ts: chrono::Utc::now(),
1167 event: WireSessionEvent::Reloading { generation: 2, retry_after_ms: 500 },
1168 });
1169 w.write_all(encode_line(&ev).unwrap().as_bytes()).await.unwrap();
1170 let bye = DaemonFrame::Bye { reason: Some(ByeReason::OwnerLagged { dropped: 3 }) };
1171 w.write_all(encode_line(&bye).unwrap().as_bytes()).await.unwrap();
1172 break;
1173 }
1174 ClientFrame::Cmd { cmd: SessionCommand::Submit { text, .. }, .. } => {
1175 seq += 1;
1176 DaemonFrame::Event(WireEnvelope {
1177 session_id: sid.clone(),
1178 seq,
1179 ts: chrono::Utc::now(),
1180 event: SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(text))).into(),
1181 })
1182 }
1183 ClientFrame::Cmd { cmd: SessionCommand::Set { id, .. }, .. } => {
1184 seq += 1;
1185 let mut view = crate::session::handle::echo::view_for();
1186 view.model = "changed".into();
1187 DaemonFrame::Event(WireEnvelope {
1188 session_id: sid.clone(),
1189 seq,
1190 ts: chrono::Utc::now(),
1191 event: SessionEventWire::SettingChanged(SettingApplied {
1192 id,
1193 setting: "model".into(),
1194 ok: true,
1195 message: None,
1196 view,
1197 clamp: None,
1198 })
1199 .into(),
1200 })
1201 }
1202 ClientFrame::Cmd { cmd: SessionCommand::Checkpoint { .. }, .. } => {
1204 seq += 1;
1205 let ev = DaemonFrame::Event(WireEnvelope {
1206 session_id: sid.clone(),
1207 seq,
1208 ts: chrono::Utc::now(),
1209 event: WireSessionEvent::Reloading { generation: 2, retry_after_ms: 500 },
1210 });
1211 w.write_all(encode_line(&ev).unwrap().as_bytes()).await.unwrap();
1212 let bye = DaemonFrame::Bye { reason: Some(ByeReason::Reloading { generation: 2, retry_after_ms: 500 }) };
1213 w.write_all(encode_line(&bye).unwrap().as_bytes()).await.unwrap();
1214 break;
1215 }
1216 ClientFrame::Cmd { cmd: SessionCommand::KeepWarm { .. }, .. } => {
1218 seq += 1;
1219 DaemonFrame::Event(WireEnvelope {
1220 session_id: sid.clone(),
1221 seq,
1222 ts: chrono::Utc::now(),
1223 event: WireSessionEvent::InputOwnerChanged {
1224 from: Some(ClientId(7)),
1225 to: Some(ClientId(9)),
1226 reason: OwnerChangeReason::Takeover,
1227 },
1228 })
1229 }
1230 ClientFrame::Cmd { cmd: SessionCommand::Steer { text }, .. } => {
1232 seq += 1;
1233 let snap = ConversationSnapshot {
1234 api_messages: vec![Arc::new(serde_json::json!({"role":"user","content":text}))],
1235 cost: 1.5,
1236 ..Default::default()
1237 };
1238 DaemonFrame::Event(WireEnvelope {
1239 session_id: sid.clone(),
1240 seq,
1241 ts: chrono::Utc::now(),
1242 event: SessionEventWire::Conversation(snap).into(),
1243 })
1244 }
1245 ClientFrame::Cmd { cmd: SessionCommand::Query { id, query: SessionQuery::Messages }, .. } => {
1246 seq += 1;
1247 DaemonFrame::Event(WireEnvelope {
1248 session_id: sid.clone(),
1249 seq,
1250 ts: chrono::Utc::now(),
1251 event: WireSessionEvent::QueryResult {
1252 id,
1253 value: serde_json::json!([{"role":"user","content":"drifted"}]),
1254 },
1255 })
1256 }
1257 ClientFrame::Bye => {
1258 let _ = w.write_all(encode_line(&DaemonFrame::Bye { reason: None }).unwrap().as_bytes()).await;
1259 break;
1260 }
1261 ClientFrame::Cmd { cmd: SessionCommand::Detach { .. }, .. } => continue,
1262 _ => DaemonFrame::Error { session_id: None, message: "nope".into() },
1263 };
1264 let bytes = encode_line(&reply).unwrap();
1265 if bytes.contains("split:") {
1268 let (a, b) = bytes.as_bytes().split_at(bytes.len() / 2);
1269 w.write_all(a).await.unwrap();
1270 w.flush().await.unwrap();
1271 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1272 w.write_all(b).await.unwrap();
1273 continue;
1274 }
1275 w.write_all(bytes.as_bytes()).await.unwrap();
1276 }
1277 }
1278
1279 fn sock() -> (tempfile::TempDir, std::path::PathBuf) {
1280 let d = tempfile::tempdir().unwrap();
1281 let p = d.path().join("d.sock");
1282 (d, p)
1283 }
1284
1285 #[test]
1290 fn predates_auth_state_matches_an_old_daemons_decode_error_and_nothing_else() {
1291 #[derive(serde::Deserialize)]
1292 #[serde(tag = "type", rename_all = "snake_case")]
1293 #[allow(dead_code, clippy::large_enum_variant)]
1294 enum OldClientFrame {
1295 Hello(Hello),
1296 Ping,
1297 Sessions,
1298 Shutdown {
1299 #[serde(default)]
1300 force: bool,
1301 },
1302 Purge,
1303 Bye,
1304 }
1305 for query in [
1306 agent_core::auth::AuthStateQuery::default(),
1307 agent_core::auth::AuthStateQuery { provider: Some("anthropic".into()), account: Some("b".into()), clear: true },
1308 ] {
1309 let line = encode_line(&ClientFrame::AuthState(query)).unwrap();
1310 let err = decode_line::<OldClientFrame>(line.trim_end()).err().expect("an old daemon cannot decode it");
1311 let reply = format!("malformed frame: {err}");
1312 assert!(predates_auth_state(&reply), "{reply}");
1313 }
1314 for other in [
1315 "auth state: unknown account `zzz`",
1316 "malformed frame: expected value at line 1 column 1",
1317 "malformed frame: unknown variant `auth_status`, expected one of `hello`, `ping`",
1318 "malformed frame: missing field `clear`",
1319 "control frames are not accepted on an attached connection",
1320 "duplicate hello",
1321 "",
1322 ] {
1323 assert!(!predates_auth_state(other), "{other:?}");
1324 }
1325 }
1326
1327 #[tokio::test]
1331 async fn next_event_survives_cancellation_mid_frame() {
1332 let (_d, path) = sock();
1333 let l = Listener::bind(&path).unwrap();
1334 let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1335 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1336 let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1337 .await
1338 .unwrap();
1339 t.send(SessionCommand::Submit { text: "split:hello".into(), attachments: vec![] }).await.unwrap();
1340 tokio::select! {
1342 e = t.next_event() => panic!("frame cannot be complete yet: {e:?}"),
1343 _ = tokio::time::sleep(std::time::Duration::from_millis(20)) => {}
1344 }
1345 let e = tokio::time::timeout(std::time::Duration::from_secs(2), t.next_event())
1346 .await
1347 .expect("no event");
1348 match e.map(|e| e.event) {
1349 Some(SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(s)))) => assert_eq!(s, "split:hello"),
1350 other => panic!("frame lost to cancellation: {other:?} (last_error={:?})", t.last_error()),
1351 }
1352 t.detach().await;
1353 let _ = srv.await;
1354 }
1355
1356 #[tokio::test]
1357 async fn socket_transport_handshake_attach_pump() {
1358 let (_d, path) = sock();
1359 let l = Listener::bind(&path).unwrap();
1360 let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1361
1362 let mut conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1363 assert_eq!(conn.welcome.protocol_version, PROTOCOL_VERSION);
1364 assert_eq!(conn.ping().await.unwrap().pid, 1);
1365 let (mut t, snap) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1366 .await
1367 .unwrap();
1368 assert_eq!(t.client_id(), ClientId(7));
1369 assert_eq!(t.mode(), AttachMode::Mirror);
1370 assert_eq!(t.input_owner(), Some(ClientId(7)));
1371 assert_eq!(snap.meta.id.as_str(), "fake-1");
1372 assert_eq!(t.session_id().as_str(), "fake-1");
1373 assert_eq!(t.view().model, "echo");
1374
1375 t.send(SessionCommand::Submit { text: "hello".into(), attachments: vec![] }).await.unwrap();
1376 let e = t.next_event().await.unwrap();
1377 match e.event {
1378 SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(s))) => assert_eq!(s, "hello"),
1379 other => panic!("{other:?}"),
1380 }
1381 t.send_from_self(SessionCommand::Set { id: 11, setting: SessionSetting::Model { model: "changed".into() } })
1382 .await
1383 .unwrap();
1384 let e = t.next_event().await.unwrap();
1385 assert!(matches!(e.event, SessionEventWire::SettingChanged(SettingApplied { id: 11, .. })));
1386 assert_eq!(t.view().model, "changed");
1387 t.send(SessionCommand::KeepWarm { on: true }).await.unwrap();
1388 let e = t.next_event().await.unwrap();
1389 assert!(matches!(e.event, SessionEventWire::InputOwnerChanged { .. }));
1390 assert_eq!(t.input_owner(), Some(ClientId(9)));
1391
1392 t.send(SessionCommand::Save).await.unwrap();
1394 assert!(matches!(t.next_event().await.unwrap().event, SessionEventWire::SystemNotice(_)));
1395 t.detach().await;
1396 assert!(t.next_event().await.is_none());
1397 assert!(t.next_event().await.is_none());
1398 srv.await.unwrap();
1399 }
1400
1401 #[tokio::test]
1402 async fn conversation_digest_miss_requeries_messages() {
1403 let (_d, path) = sock();
1404 let l = Listener::bind(&path).unwrap();
1405 let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1406 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1407 let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1408 .await
1409 .unwrap();
1410 assert!(t.messages().is_empty());
1411 t.send(SessionCommand::Steer { text: "drifted".into() }).await.unwrap();
1412 let e = t.next_event().await.unwrap();
1415 match e.event {
1416 SessionEventWire::Conversation(c) => {
1417 assert_eq!(c.api_messages.len(), 1);
1418 assert_eq!(c.api_messages[0]["content"], "drifted");
1419 assert_eq!(c.cost, 1.5);
1420 }
1421 other => panic!("{other:?}"),
1422 }
1423 assert_eq!(t.messages().len(), 1);
1424 t.send(SessionCommand::Steer { text: "drifted".into() }).await.unwrap();
1426 let e = t.next_event().await.unwrap();
1427 assert!(matches!(e.event, SessionEventWire::Conversation(ref c) if c.api_messages.len() == 1));
1428 t.detach().await;
1429 assert!(t.next_event().await.is_none());
1430 srv.await.unwrap();
1431 }
1432
1433 #[tokio::test]
1434 async fn reloading_then_bye_sets_last_error_and_reconnect_gives_up_on_a_dead_socket() {
1435 let (_d, path) = sock();
1436 let l = Listener::bind(&path).unwrap();
1437 let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1438 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1439 let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Observe })
1440 .await
1441 .unwrap();
1442 assert_eq!(t.mode(), AttachMode::Observe);
1443 assert!(t.last_error().is_none());
1444 t.send(SessionCommand::Checkpoint { reason: CheckpointReason::Reload }).await.unwrap();
1445 let e = t.next_event().await.unwrap();
1446 assert!(matches!(e.event, SessionEventWire::Reloading { generation: 2, .. }));
1447 assert!(t.next_event().await.is_none());
1448 assert!(matches!(t.last_error(), Some(TransportError::Reloading { generation: 2 })));
1449 srv.await.unwrap();
1450 std::env::set_var("SYNAPS_TUI_ATTACH_RECONNECT_SECS", "1");
1452 let r = t.reconnect(AttachMode::Observe).await;
1453 std::env::remove_var("SYNAPS_TUI_ATTACH_RECONNECT_SECS");
1454 assert!(r.is_err());
1455 }
1456
1457 #[tokio::test]
1461 async fn owner_lagged_after_a_reload_announce_is_final() {
1462 let (_d, path) = sock();
1463 let l = Listener::bind(&path).unwrap();
1464 let srv = tokio::spawn(fake_daemon(l, PROTOCOL_VERSION));
1465 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1466 let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: "fake-1".into(), mode: AttachMode::Mirror })
1467 .await
1468 .unwrap();
1469 t.send(SessionCommand::Submit { text: "lag-after-reload".into(), attachments: vec![] }).await.unwrap();
1470 let e = t.next_event().await.unwrap();
1471 assert!(matches!(e.event, SessionEventWire::Reloading { generation: 2, .. }));
1472 assert!(t.is_reload_pending());
1473 assert!(t.next_event().await.is_none());
1474 assert!(matches!(t.last_error(), Some(TransportError::OwnerLagged { dropped: 3 })), "{:?}", t.last_error());
1475 assert!(!t.is_reload_pending(), "not a reload to ride out");
1476 assert!(t.next_event().await.is_none());
1477 assert!(matches!(t.last_error(), Some(TransportError::OwnerLagged { dropped: 3 })));
1478 srv.await.unwrap();
1479 assert!(matches!(t.reconnect_once(AttachMode::Mirror).await, Err(TransportError::OwnerLagged { dropped: 3 })));
1480 assert!(matches!(
1481 ClientTransport::reconnect(&mut t, AttachMode::Mirror).await,
1482 Err(TransportError::OwnerLagged { dropped: 3 })
1483 ));
1484 }
1485
1486 #[tokio::test]
1487 async fn attach_error_frame_is_refused() {
1488 let (_d, path) = sock();
1489 let mut l = Listener::bind(&path).unwrap();
1490 let srv = tokio::spawn(async move {
1492 let stream = l.accept().await.unwrap();
1493 let (r, mut w) = crate::ipc::split_server(stream);
1494 let mut reader = BufReader::new(r);
1495 let mut line = String::new();
1496 reader.read_line(&mut line).await.unwrap();
1497 let welcome = DaemonFrame::Welcome(Welcome {
1498 protocol_version: PROTOCOL_VERSION,
1499 daemon_version: "t".into(),
1500 pid: 1,
1501 profile: None,
1502 sessions: vec![],
1503 progressive_tool_disclosure: true,
1504 generation: 1,
1505 daemon_build: String::new(),
1506 features: Vec::new(),
1507 });
1508 w.write_all(encode_line(&welcome).unwrap().as_bytes()).await.unwrap();
1509 line.clear();
1510 reader.read_line(&mut line).await.unwrap();
1511 let err = DaemonFrame::Error { session_id: None, message: "unknown session".into() };
1512 w.write_all(encode_line(&err).unwrap().as_bytes()).await.unwrap();
1513 });
1514 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1515 let err = SocketTransport::attach(conn, Attach::Existing { session_id: "nope".into(), mode: AttachMode::Mirror })
1516 .await
1517 .err()
1518 .unwrap();
1519 assert!(matches!(err, TransportError::Refused(ref m) if m == "unknown session"), "{err}");
1520 srv.await.unwrap();
1521 }
1522
1523 async fn owned_fake_daemon(mut l: Listener, features: Vec<String>) -> Vec<String> {
1527 let stream = l.accept().await.unwrap();
1528 let (r, mut w) = crate::ipc::split_server(stream);
1529 let mut reader = BufReader::new(r);
1530 let mut line = String::new();
1531 reader.read_line(&mut line).await.unwrap();
1532 let welcome = DaemonFrame::Welcome(Welcome {
1533 protocol_version: PROTOCOL_VERSION,
1534 daemon_version: "0.0.1-old".into(),
1535 pid: 1,
1536 profile: None,
1537 sessions: vec![],
1538 progressive_tool_disclosure: true,
1539 generation: 1,
1540 daemon_build: String::new(),
1541 features,
1542 });
1543 w.write_all(encode_line(&welcome).unwrap().as_bytes()).await.unwrap();
1544 let mut sent = Vec::new();
1545 loop {
1546 line.clear();
1547 if reader.read_line(&mut line).await.unwrap_or(0) == 0 {
1548 return sent;
1549 }
1550 let frame: ClientFrame = decode_line(line.trim_end()).unwrap();
1551 sent.push(format!("{frame:?}").split(['(', ' ', '{']).next().unwrap().to_string());
1552 if let ClientFrame::Attach(_) = frame {
1553 let sid = SessionId::from("fake-1");
1554 let reply = DaemonFrame::Attached(AttachedWire::new(
1555 ClientId(7),
1556 AttachSnapshot {
1557 meta: crate::session::handle::echo::meta_for(&sid),
1558 view: crate::session::handle::echo::view_for(),
1559 conversation: ConversationSnapshot::default(),
1560 streaming: false,
1561 replay: vec![],
1562 pending_prompts: vec![],
1563 clients: vec![],
1564 roster: Vec::new(),
1565 client_input: ClientInput::wire_default(),
1566 input_owner: Some(ClientId(7)),
1567 display_tail: None,
1568 },
1569 ));
1570 w.write_all(encode_line(&reply).unwrap().as_bytes()).await.unwrap();
1571 }
1572 }
1573 }
1574
1575 #[tokio::test]
1579 async fn owned_create_against_a_daemon_without_the_feature_fails_loudly() {
1580 let (_d, path) = sock();
1581 let l = Listener::bind(&path).unwrap();
1582 let srv = tokio::spawn(owned_fake_daemon(l, Vec::new()));
1583 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1584 assert!(!conn.welcome.supports(FEATURE_OWNED_SESSIONS));
1585 let err = SocketTransport::attach(conn, Attach::create_owned(SessionConfig::default(), AttachMode::Mirror, false))
1586 .await
1587 .err()
1588 .unwrap();
1589 assert!(
1590 matches!(err, TransportError::DaemonTooOld { request: "owned sessions", ref daemon_version } if daemon_version == "0.0.1-old"),
1591 "{err}"
1592 );
1593 assert!(err.to_string().contains("daemon reload"), "{err}");
1594 assert_eq!(srv.await.unwrap(), vec!["Bye".to_string()], "no Attach ever reached the daemon");
1595 }
1596
1597 #[tokio::test]
1600 async fn owned_create_without_a_token_in_the_reply_is_refused() {
1601 let (_d, path) = sock();
1602 let l = Listener::bind(&path).unwrap();
1603 let srv = tokio::spawn(owned_fake_daemon(l, vec![FEATURE_OWNED_SESSIONS.into()]));
1604 let conn = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.unwrap();
1605 let err = SocketTransport::attach(conn, Attach::create_owned(SessionConfig::default(), AttachMode::Mirror, false))
1606 .await
1607 .err()
1608 .unwrap();
1609 assert!(matches!(err, TransportError::Refused(ref m) if m.contains("did not grant an owned session")), "{err}");
1610 drop(srv);
1611 }
1612
1613 #[tokio::test]
1614 async fn socket_transport_refuses_version_mismatch() {
1615 let (_d, path) = sock();
1616 let l = Listener::bind(&path).unwrap();
1617 let srv = tokio::spawn(fake_daemon(l, 99));
1618 let err = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.err().unwrap();
1619 assert!(matches!(err, TransportError::Version { client: PROTOCOL_VERSION, daemon: 99 }), "{err}");
1620 srv.await.unwrap();
1621 }
1622
1623 #[test]
1626 fn close_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1627 let old = DaemonFrame::Error {
1628 session_id: None,
1629 message: "malformed frame: unknown variant `close`, expected one of `hello`, `ping`".into(),
1630 };
1631 let err = close_reply(Some(old), "0.9.0").unwrap_err();
1632 assert!(matches!(err, TransportError::DaemonTooOld { ref daemon_version, .. } if daemon_version == "0.9.0"), "{err}");
1633 assert!(err.to_string().contains("synaps daemon reload"), "{err}");
1634
1635 let closed = DaemonFrame::Closed { session_id: "s".into(), text: "closing session s".into() };
1636 assert_eq!(close_reply(Some(closed), "1").unwrap(), "closing session s");
1637 let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "not closed: a turn is running".into() };
1638 assert!(matches!(close_reply(Some(busy), "1"), Err(TransportError::Refused(ref m)) if m.contains("turn")));
1639 let unknown = DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message: "no live session s".into() };
1640 assert!(matches!(close_reply(Some(unknown), "1"), Err(TransportError::UnknownSession(_))));
1641 let other = DaemonFrame::Error { session_id: None, message: "close: no reply".into() };
1642 assert!(matches!(close_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1643 assert!(matches!(close_reply(None, "1"), Err(TransportError::Closed)));
1644 }
1645
1646 #[test]
1647 fn pin_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1648 let old = DaemonFrame::Error {
1649 session_id: None,
1650 message: "malformed frame: unknown variant `pin`, expected one of `hello`, `ping`".into(),
1651 };
1652 let err = pin_reply(Some(old), "0.9.0").unwrap_err();
1653 assert!(
1654 matches!(err, TransportError::DaemonTooOld { ref daemon_version, request: "daemon pin" } if daemon_version == "0.9.0"),
1655 "{err}"
1656 );
1657 assert!(err.to_string().contains("synaps daemon reload"), "{err}");
1658
1659 assert!(pin_reply(Some(DaemonFrame::Pinned { session_id: "s".into(), on: true }), "1").unwrap());
1660 assert!(!pin_reply(Some(DaemonFrame::Pinned { session_id: "s".into(), on: false }), "1").unwrap());
1661 let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading".into() };
1662 assert!(matches!(pin_reply(Some(busy), "1"), Err(TransportError::Refused(ref m)) if m.contains("reloading")));
1663 let unknown = DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message: "no live session s".into() };
1664 assert!(matches!(pin_reply(Some(unknown), "1"), Err(TransportError::UnknownSession(_))));
1665 let other = DaemonFrame::Error { session_id: None, message: "pin: timed out".into() };
1666 assert!(matches!(pin_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1667 let closed = DaemonFrame::Closed { session_id: "s".into(), text: "x".into() };
1668 assert!(matches!(pin_reply(Some(closed), "1"), Err(TransportError::Protocol(_))));
1669 assert!(matches!(pin_reply(None, "1"), Err(TransportError::Closed)));
1670 }
1671
1672 #[test]
1673 fn rename_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1674 use crate::session::wire::RenameRefusal;
1675 let old = DaemonFrame::Error {
1676 session_id: None,
1677 message: "malformed frame: unknown variant `rename`, expected one of `hello`, `ping`".into(),
1678 };
1679 let err = rename_reply(Some(old), "0.9.0").unwrap_err();
1680 assert!(
1681 matches!(err, TransportError::DaemonTooOld { ref daemon_version, request: "daemon rename" } if daemon_version == "0.9.0"),
1682 "{err}"
1683 );
1684 assert!(err.to_string().contains("predates `daemon rename`"), "{err}");
1685
1686 let ok = DaemonFrame::Renamed { session_id: "s".into(), name: Some("axon".into()), in_memory_only: false };
1687 assert_eq!(rename_reply(Some(ok), "1").unwrap(), Ok(Renamed { name: Some("axon".into()), in_memory_only: false }));
1688 let cleared = DaemonFrame::Renamed { session_id: "s".into(), name: None, in_memory_only: true };
1689 assert_eq!(rename_reply(Some(cleared), "1").unwrap(), Ok(Renamed { name: None, in_memory_only: true }));
1690 let taken = DaemonFrame::RenameFailed {
1691 session_id: "s".into(),
1692 reason: RenameRefusal::Taken,
1693 message: "name 'axon' already used by session t".into(),
1694 taken_by: Some("t".into()),
1695 current_name: None,
1696 };
1697 let failure = rename_reply(Some(taken), "1").unwrap().unwrap_err();
1698 assert_eq!((failure.reason, failure.taken_by.as_deref()), (RenameRefusal::Taken, Some("t")));
1699 let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading".into() };
1700 assert!(matches!(rename_reply(Some(busy), "1"), Err(TransportError::Refused(_))));
1701 let unknown = DaemonFrame::Refused { reason: RefuseReason::UnknownSession, message: "no live session s".into() };
1702 assert!(matches!(rename_reply(Some(unknown), "1"), Err(TransportError::UnknownSession(_))));
1703 let other = DaemonFrame::Error { session_id: None, message: "rename: no reply".into() };
1704 assert!(matches!(rename_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1705 assert!(matches!(rename_reply(None, "1"), Err(TransportError::Closed)));
1706 }
1707
1708 #[test]
1709 fn reload_extension_maps_each_daemon_answer_and_an_old_daemons_unknown_variant_error() {
1710 let old = DaemonFrame::Error {
1711 session_id: None,
1712 message: "malformed frame: unknown variant `reload_extension`, expected one of `hello`, `ping`".into(),
1713 };
1714 let err = reload_extension_reply(Some(old), "0.9.0").unwrap_err();
1715 assert!(
1716 matches!(err, TransportError::DaemonTooOld { ref daemon_version, request: "daemon reload-extension" } if daemon_version == "0.9.0"),
1717 "{err}"
1718 );
1719
1720 let report = ExtensionReloadReport {
1721 extension: "axel".into(),
1722 was_loaded: true,
1723 deferred: false,
1724 tools: vec!["axel:axel_search".into()],
1725 removed_tools: vec![],
1726 plugin_dir: std::path::PathBuf::from("/p/axel"),
1727 };
1728 let ok = DaemonFrame::ExtensionReloaded { report: report.clone() };
1729 assert_eq!(reload_extension_reply(Some(ok), "1").unwrap(), Ok(report));
1730 let failed = DaemonFrame::ExtensionReloadFailed { extension: "axel".into(), message: "spawn failed".into(), unloaded: true };
1731 assert_eq!(
1732 reload_extension_reply(Some(failed), "1").unwrap(),
1733 Err(ExtensionReloadFailure { message: "spawn failed".into(), unloaded: true })
1734 );
1735 let busy = DaemonFrame::Refused { reason: RefuseReason::Busy, message: "daemon reloading".into() };
1736 assert!(matches!(reload_extension_reply(Some(busy), "1"), Err(TransportError::Refused(ref m)) if m.contains("reloading")));
1737 let other = DaemonFrame::Error { session_id: None, message: "frame too large".into() };
1739 assert!(matches!(reload_extension_reply(Some(other), "1"), Err(TransportError::Protocol(_))));
1740 let pinned = DaemonFrame::Pinned { session_id: "s".into(), on: true };
1741 assert!(matches!(reload_extension_reply(Some(pinned), "1"), Err(TransportError::Protocol(_))));
1742 assert!(matches!(reload_extension_reply(None, "1"), Err(TransportError::Closed)));
1743 }
1744
1745 #[tokio::test]
1746 async fn socket_transport_connect_refused_when_nobody_listens() {
1747 let (_d, path) = sock();
1748 let err = SocketTransport::connect(&path, Hello::new(ClientKind::Test)).await.err().unwrap();
1749 assert!(matches!(err, TransportError::Io(_)));
1750 }
1751}