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::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);
26/// How long `close` waits for the daemon's answer: its queue retry, the
27/// actor's reply and the session's end (`daemon/close.rs` budgets), plus a
28/// margin.
29pub 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);
35/// How long `pin` waits for the daemon's answer: its longest wait (a parked
36/// session unparking) plus a margin.
37pub const PIN_TIMEOUT: Duration =
38    Duration::from_secs(crate::daemon::pin::PIN_UNPARK_BUDGET.as_secs() + 2);
39/// How long `rename` waits for the daemon's answer: its queue retry and the
40/// actor's reply (`daemon/rename.rs` budgets), plus a margin.
41pub 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);
44/// How long `reload_extension` waits: the daemon first waits for the
45/// extension manager's lock, then stops the old process and runs the new
46/// one's start-up handshake (each bounded by the extension runtime's own
47/// timeouts, around 15 s together). Generous, because giving up early only
48/// hides the answer: the daemon finishes the reload either way.
49pub const RELOAD_EXTENSION_TIMEOUT: Duration = Duration::from_secs(60);
50
51/// A connection past `Hello`/`Welcome`, before `Attach`.
52pub struct Connected {
53    reader: BufReader<ClientReadHalf>,
54    writer: mpsc::Sender<ClientFrame>,
55    writer_task: tokio::task::JoinHandle<()>,
56    pub welcome: Welcome,
57    /// Remembered for `reconnect`.
58    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/// What `synaps auth state` got from the daemon.
70#[derive(Debug)]
71pub enum AuthStateReply {
72    Report(agent_core::auth::AuthStateReport),
73    /// The daemon predates `AuthState`: it answered the frame the way an
74    /// old daemon answers any frame it cannot decode. Its version and build
75    /// (empty when the daemon does not report one), from its `Welcome`.
76    DaemonPredates { daemon_version: String, daemon_build: String },
77}
78
79/// An old daemon's reply to `AuthState` (daemon/conn.rs `Read::Bad`: serde's
80/// unknown-variant error behind "malformed frame: "), and nothing else.
81pub fn predates_auth_state(message: &str) -> bool {
82    message.starts_with("malformed frame: unknown variant `auth_state`")
83}
84
85/// Reused line buffer for `read_frame`: a big `Attached`/`MessageHistory`
86/// frame grows it once; after any frame > 1 MiB it shrinks back to 64 KiB so
87/// the peak is not retained for the life of the connection (§2.1 #1).
88#[derive(Default)]
89pub struct FrameBuf {
90    line: Vec<u8>,
91    /// Length of the last decoded frame (bytes, without the newline).
92    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    // Cancel-safe: the TUI polls this inside `select!`, so a partial line
110    // stays in `buf.line` across a dropped future and is cleared only once
111    // a whole frame has been taken out of it.
112    loop {
113        // `take` bounds the read so an oversize frame cannot grow memory.
114        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    /// Hello/Welcome only. `Refused{Version}` → `TransportError::Version`;
168    /// any other refusal → `Protocol`.
169    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    /// `Purge` → the daemon runs `memstat::purge_arenas()`; replies `Pong`.
227    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    /// `AuthState` → the daemon broker's in-memory account state. A daemon
236    /// that predates the frame is [`AuthStateReply::DaemonPredates`]; any
237    /// other error is returned as itself.
238    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    /// `Close{id, force, attached}`: end one session with host authority.
261    /// `Ok(text)` = the session ended (the actor's `/close` reply, with how
262    /// to continue it). A refusal (work in flight, clients attached without
263    /// `attached`, daemon reloading) → `Refused(text)`;
264    /// no such session → `UnknownSession`; a daemon that predates `Close`
265    /// → `DaemonTooOld`. Waits up to [`CLOSE_TIMEOUT`]: the session saves
266    /// and runs its end hooks before the daemon answers.
267    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    /// `Pin{id, on}`: pin or unpin one session with host authority.
279    /// `Ok(on)` = the session's pin is now `on`. A refusal (daemon
280    /// reloading, session live in another process) → `Refused(text)`; no
281    /// such session → `UnknownSession`; a daemon that predates `Pin` →
282    /// `DaemonTooOld`.
283    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    /// `Rename{id, name, expect}`: name (`Some`) or unname (`None`) one
295    /// session with host authority, without attaching. `expect` is the
296    /// compare-and-set precondition (`Some(None)` = only if unnamed).
297    /// `Ok(Ok(renamed))` = done; `Ok(Err(failure))` = the daemon says why
298    /// not, or `Unknown` when no answer came (timeout, connection lost after
299    /// sending: it may still apply). No such session → `UnknownSession`; a
300    /// daemon that predates `Rename` → `DaemonTooOld` (never a hang: it
301    /// answers the unknown frame at once).
302    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    /// `ReloadExtension{extension}`: stop that extension and start it again
328    /// from what is installed now. `Ok(Ok(report))` = reloaded;
329    /// `Ok(Err(failure))` = the daemon tried and says why not; a refusal
330    /// (daemon reloading) → `Refused(text)`; a daemon that predates the
331    /// frame → `DaemonTooOld`.
332    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    /// Say goodbye and drop the connection.
347    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
353/// The daemon's answer to `Close`, as [`Connected::close`] reports it.
354fn 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        // An older daemon cannot decode the frame; it answers with serde's
362        // "unknown variant `close`" and keeps the connection open.
363        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
372/// The daemon's answer to `Pin`, as [`Connected::pin`] reports it.
373fn 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        // An older daemon cannot decode the frame; it answers with serde's
381        // "unknown variant `pin`" and keeps the connection open.
382        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/// A rename the daemon applied (`DaemonFrame::Renamed`).
392#[derive(Debug, Clone, PartialEq, Eq)]
393pub struct Renamed {
394    /// The session's name now (`None` = cleared).
395    pub name: Option<String>,
396    /// The session is not persisted: nothing was written.
397    pub in_memory_only: bool,
398}
399
400/// Why the daemon did not rename a session (`DaemonFrame::RenameFailed`).
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct RenameFailure {
403    pub reason: crate::session::wire::RenameRefusal,
404    pub message: String,
405    /// Journal id of the saved session that already has the name (`Taken`).
406    pub taken_by: Option<String>,
407    /// The session's name when the `expect` precondition failed (`Named`).
408    pub current_name: Option<String>,
409}
410
411/// The daemon's answer to `Rename`, as [`Connected::rename`] reports it.
412fn 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        // An older daemon cannot decode the frame; it answers with serde's
426        // "unknown variant `rename`" and keeps the connection open.
427        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/// The daemon's reason an extension was not reloaded
439/// (`DaemonFrame::ExtensionReloadFailed`).
440#[derive(Debug, Clone, PartialEq, Eq)]
441pub struct ExtensionReloadFailure {
442    pub message: String,
443    /// The old instance was stopped before the new one failed to start: the
444    /// extension is not loaded now. `false` = the running one (if any) was
445    /// not touched.
446    pub unloaded: bool,
447}
448
449/// The daemon's answer to `ReloadExtension`, as
450/// [`Connected::reload_extension`] reports it.
451fn 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        // An older daemon cannot decode the frame; it answers with serde's
462        // "unknown variant `reload_extension`" and keeps the connection open.
463        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    /// `Reloading` seen; the next EOF is a reload, not a death.
492    reload_pending: Option<u64>,
493    /// Why `next_event` returned `None` (`Reloading{generation}` after a
494    /// reload announcement) — the TUI branches to `reconnect` on it.
495    last_error: Option<TransportError>,
496    /// Local `api_messages` mirror (`HistoryMode::Full` only): seeded by
497    /// `Attached`, updated by `Stream(MessageHistory)` and
498    /// `QueryResult{DIGEST_RESYNC_QUERY_ID}`. `Conversation` arrives as a
499    /// digest; the mirror fills it in. Empty forever in `Digest` mode.
500    messages: Vec<crate::SharedMessage>,
501    /// Digest awaiting a `Messages` re-query (hash miss; Full mode only).
502    pending_digest: Option<ConversationDigest>,
503    /// What we asked for in `Hello` (phase 4 §2.3).
504    history: HistoryMode,
505    buf: FrameBuf,
506    /// Owned sessions: this connection is the owner. The token the daemon
507    /// minted, plus the secrets forwarded at create, sent again on a
508    /// re-bind after a reload (`reconnect`).
509    owner: Option<OwnerClaim>,
510}
511
512impl SocketTransport {
513    /// Hello/Welcome only (50 ms connect + 2 s handshake budgets).
514    pub async fn connect(path: &Path, hello: Hello) -> Result<Connected, TransportError> {
515        Connected::connect(path, hello).await
516    }
517
518    /// Attach (existing or create) on a handshaken connection. `AttachRefused`
519    /// and `DaemonFrame::Error` alike → `TransportError::Refused`. Waits
520    /// `ATTACH_TIMEOUT_PARKED` when the daemon lists the session as `Parked`.
521    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        // Owned: an older daemon would ignore the request and hand back a
533        // session that outlives us. Never ask one that does not say it can.
534        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        // The owner re-binding after a reload (`reconnect`).
544        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                    // Notices/lifecycle chatter may precede `Attached` (unpark).
564                    _ => 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    /// Whether this connection owns its session (an owned create, or a
617    /// re-bind): the session ends when this connection goes away.
618    pub fn is_session_owner(&self) -> bool {
619        self.owner.is_some()
620    }
621
622    /// Connect and create an OWNED session in one call (headless clients:
623    /// `synaps rpc`, `synaps chat`): the session ends, its turn cancelled,
624    /// when this transport is dropped, detaches, or its process dies.
625    /// `forward_secrets` also hands the daemon this process's secret env
626    /// vars (the ones `Hello` strips) for the session's tools, in memory
627    /// only. Fails with `DaemonTooOld` against a daemon without the
628    /// feature; never falls back to an unowned session.
629    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    /// Why the last `next_event` returned `None` (if it was not a plain end).
641    pub fn last_error(&self) -> Option<&TransportError> {
642        self.last_error.as_ref()
643    }
644
645    /// The daemon cut this owner for lagging (`Bye{owner_lagged}`): events
646    /// were lost, so there is nothing to reconnect to. Wins over a reload.
647    fn owner_lagged(&self) -> Option<u64> {
648        match self.last_error {
649            Some(TransportError::OwnerLagged { dropped }) => Some(dropped),
650            _ => None,
651        }
652    }
653
654    /// The stream ended: after a `Reloading`, that is the reload (unless
655    /// the owner lagged, which stays the error).
656    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    /// `true` if the daemon announced a reload before EOF (graceful); `false`
666    /// means a crash/SIGKILL (unexpected).
667    pub fn is_reload_pending(&self) -> bool {
668        self.reload_pending.is_some()
669    }
670
671    /// The pid the daemon reported in `Welcome`.
672    pub fn daemon_pid(&self) -> u32 {
673        self.welcome.pid
674    }
675
676    /// The reconnect budget from `SYNAPS_TUI_ATTACH_RECONNECT_SECS` (default 60).
677    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    /// One attempt at reconnecting (Hello + Attach). On success replaces
686    /// `self` and returns the snapshot. On `Refused` / `Version` the error is
687    /// terminal (caller should stop retrying). Other errors are transient.
688    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    // ── control fast path helpers (fresh connection each) ──
718
719    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    /// `Close{id, force, attached}` on a fresh control connection (see
746    /// [`Connected::close`]).
747    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    /// `Pin{id, on}` on a fresh control connection (see [`Connected::pin`]).
755    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    /// `Rename{id, name}` on a fresh control connection (see
763    /// [`Connected::rename`]).
764    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    /// `AuthState` on a control connection (see [`Connected::auth_state`]).
777    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    /// `Reload{..}` on a control connection. `Ok(generation)` once the daemon
785    /// answered `Bye{Reloading{generation}}` (it is about to exec);
786    /// `Refused{ReloadRefused}` / `Error` → `Err`.
787    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        // Drain + checkpoint can take a while: wait up to drain + 60 s.
794        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    /// Detach cleanly: `Detach` + `Bye`. The turn keeps running in the daemon.
809    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    /// The client's view of `api_messages` (kept current by the digest
822    /// protocol in `Full` mode; always empty in `Digest` mode).
823    pub fn messages(&self) -> &[crate::SharedMessage] {
824        &self.messages
825    }
826
827    /// Length in bytes of the last frame decoded on this connection
828    /// (the `Attached` frame right after `attach`).
829    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    /// Turn a wire envelope into the in-process one, keeping the message
838    /// mirror honest. `None` = swallowed (reserved query id, or a digest that
839    /// needs a re-query first — the `Conversation` is re-emitted once the
840    /// `Messages` result lands).
841    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                // Mirror drifted (compaction, abort repair, flushed events): re-fetch.
857                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    /// `HistoryMode::Digest`: no mirror, no `matches()` re-serialisation, no
898    /// `Query{Messages}`. `Conversation` is passed through with an empty
899    /// `api_messages` (`messages_len` from the digest); a stray
900    /// `MessageHistory` is dropped (the daemon should not send one).
901    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    /// The daemon's conn stamps `from` with this connection's client id, so
941    /// on the wire `send` and `send_from_self` are the same frame.
942    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                        // Events were lost: final, whatever came first (a
972                        // `Reloading` included). Not a reload to ride out.
973                        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    /// C3: after `Reloading`/EOF — backoff 100 ms ×2 → cap 5 s, total ≤
1026    /// `SYNAPS_TUI_ATTACH_RECONNECT_SECS` (60); `Hello{reconnect_of}` →
1027    /// `Attach::Existing{alias-resolved id, mode}` where `mode = Takeover`
1028    /// iff this client was the input owner (§2.7: two reconnecting mirrors
1029    /// cannot both take over). On success `self` IS the new connection; the
1030    /// returned snapshot is a full re-mirror.
1031    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        // An owner too: a re-bind after its session ended is refused
1047        // ("unknown session"), which stops the loop; capping the budget at
1048        // the grace would give up while a slow restore is still running.
1049        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        // Delegate to the inherent method (same name, not trait-dispatched).
1080        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    /// A hand-rolled daemon: Welcome, answer Ping, Attach → Attached, then
1107    /// echo every Cmd::Submit as an Event stream.
1108    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                // A reload announced, then the owner cut for lagging.
1161                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                // Checkpoint = "the daemon is about to reload": Reloading event, then Bye{Reloading}.
1203                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                // KeepWarm = ownership change notice.
1217                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                // Steer = "the daemon's history drifted": digest for [user:text] without a MessageHistory.
1231                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            // `split:` Submit text: the frame arrives in two writes 50 ms
1266            // apart (a frame split across reads, as a large one can be).
1267            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    /// A daemon built before `AuthState` decodes the frame into a
1286    /// `ClientFrame` without that variant, fails, and answers
1287    /// `format!("malformed frame: {e}")` (daemon/conn.rs `Read::Bad`). That
1288    /// reply, and no other error, means "the daemon predates auth state".
1289    #[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    /// The TUI polls `next_event()` inside `tokio::select!` next to a 16 ms
1328    /// tick, so the future is dropped whenever the tick wins. A frame split
1329    /// across reads must survive that cancellation.
1330    #[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        // First half is in; the tick wins and drops the read.
1341        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        // Error → SystemNotice; Bye → None.
1393        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        // The digest misses the (empty) mirror → transport re-queries → one
1413        // Conversation with the fetched history and the digest's cost.
1414        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        // Same digest again now matches the mirror: no re-query, served locally.
1425        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        // Nobody listens any more: the backoff loop gives up at the budget.
1451        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    /// A reload announced, then the owner cut for lagging: events were
1458    /// lost, so `OwnerLagged` wins. No reload pending, and a reconnect
1459    /// refuses without dialling.
1460    #[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        // A daemon that welcomes, then answers the Attach with an Error frame.
1491        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    /// A daemon welcoming with `features` and answering the Attach with an
1524    /// `Attached` that carries no owner token; returns the frame types the
1525    /// client sent after `Hello`.
1526    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    /// An older daemon would ignore `owned` and hand back a session that
1576    /// outlives the client: the client never sends the request to a daemon
1577    /// that does not advertise the feature, and says why.
1578    #[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    /// A daemon that advertises the feature but grants no owner token: the
1598    /// client refuses the session rather than run unowned.
1599    #[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    /// An older daemon answers `Close` with serde's unknown-variant error;
1624    /// the client reports "too old" (exit 2), not a generic failure.
1625    #[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        // A plain `Error` (not the unknown-variant one) stays a protocol error.
1738        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}