Skip to main content

agent_engine/session/
socket_transport.rs

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