1use std::sync::Arc;
8
9use tokio::sync::broadcast;
10
11use super::handle::SessionHandle;
12use super::types::{
13 AttachMode, AttachSnapshot, ClientId, ClientInput, ClientMeta, Envelope, Roster, SessionCommand,
14 SessionEventWire, SessionId, SessionMeta,
15};
16use super::view::RuntimeView;
17
18#[derive(Debug, thiserror::Error)]
19pub enum TransportError {
20 #[error("session closed")]
21 Closed,
22 #[error("backpressure")]
23 Backpressure,
24 #[error("io: {0}")]
25 Io(#[from] std::io::Error),
26 #[error("protocol: {0}")]
27 Protocol(String),
28 #[error("version: client {client} daemon {daemon}")]
29 Version { client: u32, daemon: u32 },
30 #[error("unsupported: {0}")]
33 Unsupported(&'static str),
34 #[error("daemon reloading (generation {generation})")]
36 Reloading { generation: u64 },
37 #[error("refused: {0}")]
40 Refused(String),
41 #[error("unknown session: {0}")]
44 UnknownSession(String),
45 #[error("daemon {daemon_version} predates `{request}`; run `synaps daemon reload`")]
49 DaemonTooOld { daemon_version: String, request: &'static str },
50 #[error("owner connection lagged: {dropped} event(s) dropped; the daemon closed it and ended the session")]
54 OwnerLagged { dropped: u64 },
55}
56
57#[async_trait::async_trait]
58pub trait ClientTransport: Send {
59 fn session_id(&self) -> &SessionId;
60 fn meta(&self) -> &SessionMeta;
61 async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError>;
63 async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError>;
66 async fn next_event(&mut self) -> Option<Envelope>;
68 fn view(&self) -> Arc<RuntimeView>;
71 fn client_id(&self) -> ClientId;
73 fn mode(&self) -> AttachMode;
75 fn input_owner(&self) -> Option<ClientId>;
77 fn roster(&self) -> Roster {
80 Roster::default()
81 }
82 fn client_input(&self) -> ClientInput {
85 ClientInput::wire_default()
86 }
87 async fn reconnect(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError>;
90 async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
92 self.reconnect(mode).await
93 }
94 fn is_reload_pending(&self) -> bool {
97 false
98 }
99 fn daemon_pid(&self) -> u32 {
101 0
102 }
103 fn daemon_binary(&self) -> Option<BinaryId> {
106 None
107 }
108 fn socket_path(&self) -> Option<&std::path::Path> {
112 None
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct BinaryId {
121 pub version: String,
122 pub build: String,
123}
124
125impl BinaryId {
126 pub fn this() -> Self {
128 Self { version: super::wire::binary_version(), build: super::wire::build_id().to_string() }
129 }
130
131 pub fn of(welcome: &super::wire::Welcome) -> Self {
133 Self { version: welcome.daemon_version.clone(), build: welcome.daemon_build.clone() }
134 }
135
136 fn label(&self, show_build: bool) -> String {
137 if show_build && !self.build.is_empty() {
138 format!("{} ({})", self.version, self.build)
139 } else {
140 self.version.clone()
141 }
142 }
143}
144
145pub fn version_skew_notice(client: &BinaryId, daemon: &BinaryId, session: &str) -> Option<String> {
160 let builds_differ = !client.build.is_empty() && !daemon.build.is_empty() && client.build != daemon.build;
161 if client.version == daemon.version && !builds_differ {
162 return None;
163 }
164 let show_build = client.version == daemon.version;
165 let (c, d) = (client.label(show_build), daemon.label(show_build));
166 Some(match compare_versions(&client.version, &daemon.version) {
167 Some(std::cmp::Ordering::Less) => format!(
168 "this client is synaps {c} but the daemon is {d} — this client is older, so some \
169 commands and fixes are unavailable here. Restart the client to match the daemon: \
170 synaps --attach {session}"
171 ),
172 Some(std::cmp::Ordering::Greater) => format!(
173 "this client is synaps {c} but the daemon is {d} — the daemon is older than this \
174 client, so some commands and fixes are unavailable. To move it onto this build, \
175 run `synaps daemon reload`"
176 ),
177 _ => format!(
178 "this client is synaps {c} but the daemon is {d} — different builds, so some commands \
179 and fixes may be unavailable. If this client is the newer one, run `synaps daemon \
180 reload`; otherwise restart the client to match the daemon: synaps --attach {session}"
181 ),
182 })
183}
184
185fn compare_versions(a: &str, b: &str) -> Option<std::cmp::Ordering> {
188 fn parts(v: &str) -> Option<Vec<u64>> {
189 let core = v.trim().trim_start_matches('v').split(['-', '+']).next()?;
190 core.split('.').map(|p| p.parse().ok()).collect()
191 }
192 Some(parts(a)?.cmp(&parts(b)?))
193}
194
195pub const ATTACH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
197pub const ATTACH_TIMEOUT_PARKED: std::time::Duration = std::time::Duration::from_secs(25);
199
200pub struct LocalTransport {
201 handle: SessionHandle,
202 rx: broadcast::Receiver<Envelope>,
203 client: ClientId,
204 meta: SessionMeta,
205 mode: AttachMode,
206 input_owner: Option<ClientId>,
207 roster: Roster,
208 client_input: ClientInput,
209 ended: bool,
211}
212
213impl LocalTransport {
214 pub async fn attach(
216 handle: SessionHandle,
217 meta: ClientMeta,
218 ) -> Result<(Self, AttachSnapshot), TransportError> {
219 Self::attach_with(handle, meta, AttachMode::Mirror).await
220 }
221
222 pub async fn attach_with(
225 handle: SessionHandle,
226 meta: ClientMeta,
227 mode: AttachMode,
228 ) -> Result<(Self, AttachSnapshot), TransportError> {
229 let mut rx = handle.subscribe();
233 handle
234 .send(SessionCommand::Attach {
235 client: meta,
236 mode,
237 owner: None,
238 })
239 .await?;
240
241 let wait = async {
242 loop {
243 match rx.recv().await {
244 Ok(env) => match env.event {
245 SessionEventWire::Attached { client, snapshot } => {
246 return Ok((client, snapshot));
247 }
248 SessionEventWire::AttachRefused { message } => {
249 return Err(TransportError::Refused(message));
250 }
251 _ => {}
252 },
253 Err(broadcast::error::RecvError::Lagged(_)) => continue,
254 Err(broadcast::error::RecvError::Closed) => {
255 return Err(TransportError::Closed)
256 }
257 }
258 }
259 };
260 let timeout = if handle.lifecycle() == super::types::SessionLifecycle::Parked {
261 ATTACH_TIMEOUT_PARKED
262 } else {
263 ATTACH_TIMEOUT
264 };
265 let (client, snapshot) = match tokio::time::timeout(timeout, wait).await {
266 Ok(r) => r?,
267 Err(_) => return Err(TransportError::Protocol("attach timed out".into())),
268 };
269 let meta = snapshot.meta.clone();
270 Ok((
271 Self {
272 handle,
273 rx,
274 client,
275 meta,
276 mode,
277 input_owner: snapshot.input_owner,
278 roster: Roster::from_snapshot(&snapshot),
279 client_input: snapshot.client_input,
280 ended: false,
281 },
282 snapshot,
283 ))
284 }
285
286 pub fn handle(&self) -> &SessionHandle {
287 &self.handle
288 }
289}
290
291#[async_trait::async_trait]
292impl ClientTransport for LocalTransport {
293 fn session_id(&self) -> &SessionId {
294 &self.handle.id
295 }
296
297 fn meta(&self) -> &SessionMeta {
298 &self.meta
299 }
300
301 async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError> {
302 self.handle.send(cmd).await
303 }
304
305 async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError> {
306 self.handle.send_from(self.client, cmd).await
307 }
308
309 async fn next_event(&mut self) -> Option<Envelope> {
310 if self.ended {
311 return None;
312 }
313 let recv = tokio::select! {
317 biased;
318 r = self.rx.recv() => r,
319 _ = self.handle.closed() => match self.rx.try_recv() {
320 Ok(env) => Ok(env),
321 Err(broadcast::error::TryRecvError::Lagged(n)) => {
322 Err(broadcast::error::RecvError::Lagged(n))
323 }
324 Err(_) => Err(broadcast::error::RecvError::Closed),
325 },
326 };
327 match recv {
328 Ok(env) => {
329 match &env.event {
330 SessionEventWire::Ended { .. } => self.ended = true,
331 SessionEventWire::InputOwnerChanged { to, .. } => self.input_owner = *to,
332 other => self.roster.apply(other),
333 }
334 Some(env)
335 }
336 Err(broadcast::error::RecvError::Lagged(n)) => {
337 tracing::warn!(session = %self.handle.id, dropped = n, "session event stream lagged");
339 Some(Envelope {
340 session_id: self.handle.id.clone(),
341 seq: u64::MAX,
342 ts: chrono::Utc::now(),
343 event: SessionEventWire::SystemNotice(format!(
344 "event stream lagged; {n} dropped"
345 )),
346 })
347 }
348 Err(broadcast::error::RecvError::Closed) => {
349 self.ended = true;
350 None
351 }
352 }
353 }
354
355 fn view(&self) -> Arc<RuntimeView> {
356 Arc::clone(&self.handle.view())
357 }
358
359 fn client_id(&self) -> ClientId {
360 self.client
361 }
362
363 fn mode(&self) -> AttachMode {
364 self.mode
365 }
366
367 fn input_owner(&self) -> Option<ClientId> {
368 self.input_owner
369 }
370
371 fn roster(&self) -> Roster {
372 self.roster.clone()
373 }
374
375 fn client_input(&self) -> ClientInput {
376 self.client_input
377 }
378
379 async fn reconnect(&mut self, _mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
380 Err(TransportError::Unsupported("reconnect: in-process transport"))
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 #[test]
387 fn skew_notice_only_when_the_binaries_differ() {
388 use super::{version_skew_notice, BinaryId};
389 let bin = |version: &str, build: &str| BinaryId { version: version.into(), build: build.into() };
390 assert_eq!(version_skew_notice(&bin("0.10.2", "cd1fbf50"), &bin("0.10.2", "cd1fbf50"), "s"), None);
391 let n = version_skew_notice(&bin("0.10.1", "be670c09"), &bin("0.10.2", "cd1fbf50"), "20261011-041552-79cc")
393 .unwrap();
394 assert!(n.starts_with("this client is synaps 0.10.1 but the daemon is 0.10.2 —"), "{n}");
395 assert!(n.ends_with("synaps --attach 20261011-041552-79cc"), "{n}");
396 let n = version_skew_notice(&bin("0.10.2", "ae490b32"), &bin("0.10.2", "cd1fbf50"), "s").unwrap();
398 assert!(n.starts_with("this client is synaps 0.10.2 (ae490b32) but the daemon is 0.10.2 (cd1fbf50)"), "{n}");
399 assert_eq!(version_skew_notice(&bin("0.10.2", "ae490b32"), &bin("0.10.2", ""), "s"), None);
402 assert_eq!(version_skew_notice(&bin("0.10.2", ""), &bin("0.10.2", "cd1fbf50"), "s"), None);
403 }
404
405 #[test]
408 fn skew_notice_names_the_side_that_is_behind() {
409 use super::{version_skew_notice, BinaryId};
410 let bin = |version: &str, build: &str| BinaryId { version: version.into(), build: build.into() };
411 let n = version_skew_notice(&bin("0.10.3", "be670c09"), &bin("0.10.2", "cd1fbf50"), "s").unwrap();
413 assert!(n.starts_with("this client is synaps 0.10.3 but the daemon is 0.10.2 —"), "{n}");
414 assert!(n.contains("run `synaps daemon reload`"), "{n}");
415 assert!(!n.to_lowercase().contains("restart the client"), "{n}");
416 assert!(!n.contains("synaps --attach"), "{n}");
417 let n = version_skew_notice(&bin("0.10.0", ""), &bin("0.9.9", ""), "s").unwrap();
419 assert!(n.contains("run `synaps daemon reload`"), "{n}");
420 let n = version_skew_notice(&bin("0.9.9", ""), &bin("0.10.0", ""), "s").unwrap();
422 assert!(n.contains("Restart the client"), "{n}");
423 assert!(!n.contains("daemon reload"), "{n}");
424 let n = version_skew_notice(&bin("0.10.2", "ae490b32"), &bin("0.10.2", "cd1fbf50"), "s").unwrap();
426 assert!(n.contains("run `synaps daemon reload`"), "{n}");
427 assert!(n.contains("restart the client"), "{n}");
428 assert!(n.contains("synaps --attach s"), "{n}");
429 }
430
431 #[test]
433 fn welcome_without_a_build_parses() {
434 let w: super::super::wire::Welcome = serde_json::from_value(serde_json::json!({
435 "protocol_version": 3, "daemon_version": "0.10.1", "pid": 7, "profile": null,
436 "sessions": [], "progressive_tool_disclosure": true, "generation": 2
437 }))
438 .unwrap();
439 assert_eq!(super::BinaryId::of(&w), super::BinaryId { version: "0.10.1".into(), build: String::new() });
440 }
441
442 use super::*;
443 use crate::session::types::*;
444 use crate::{LlmEvent, SessionEvent, StreamEvent};
445
446 async fn next(t: &mut LocalTransport) -> Envelope {
447 tokio::time::timeout(std::time::Duration::from_secs(2), t.next_event())
448 .await
449 .expect("timely")
450 .expect("open")
451 }
452
453 async fn collect(t: &mut LocalTransport) -> Vec<String> {
455 let mut out = Vec::new();
456 loop {
457 let env = next(t).await;
458 let done = matches!(env.event, SessionEventWire::Conversation(_));
459 if matches!(env.event, SessionEventWire::Stream(_)) {
460 out.push(format!("{:?}", env.event));
461 }
462 if done {
463 break;
464 }
465 }
466 out
467 }
468
469 #[tokio::test]
470 async fn local_transport_round_trip_over_echo() {
471 let (handle, task) = SessionHandle::echo_for_test(SessionId::from("lt-1"));
472 let (mut t, snap) = LocalTransport::attach(handle, ClientMeta::new(ClientKind::Test))
473 .await
474 .unwrap();
475 assert_eq!(t.session_id().as_str(), "lt-1");
476 assert_eq!(t.client_id(), ClientId(1));
477 assert_eq!(t.mode(), AttachMode::Mirror);
478 assert!(t.input_owner().is_none());
479 assert!(matches!(t.reconnect(AttachMode::Mirror).await, Err(TransportError::Unsupported(_))));
480 assert_eq!(snap.meta.model, "echo");
481 assert_eq!(t.view().model, "echo");
482 assert!(snap.conversation.api_messages.is_empty());
483
484 let joined = next(&mut t).await;
486 assert!(matches!(
487 joined.event,
488 SessionEventWire::ClientJoined {
489 client: ClientId(1),
490 kind: ClientKind::Test,
491 ..
492 }
493 ));
494
495 t.send(SessionCommand::Submit {
496 text: "ping".into(),
497 attachments: vec![],
498 })
499 .await
500 .unwrap();
501
502 let mut seen = Vec::new();
503 loop {
504 let env = next(&mut t).await;
505 let done = matches!(env.event, SessionEventWire::Conversation(_));
506 seen.push(env);
507 if done {
508 break;
509 }
510 }
511 assert!(matches!(seen[0].event, SessionEventWire::TurnStarted { .. }));
512 match &seen[1].event {
513 SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(t))) => assert_eq!(t, "ping"),
514 other => panic!("unexpected {other:?}"),
515 }
516 assert!(matches!(
517 seen[3].event,
518 SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done))
519 ));
520 let seqs: Vec<u64> = seen.iter().map(|e| e.seq).collect();
521 assert!(seqs.windows(2).all(|w| w[1] == w[0] + 1), "gapless: {seqs:?}");
522
523 t.send(SessionCommand::Query {
525 id: 7,
526 query: SessionQuery::View,
527 })
528 .await
529 .unwrap();
530 let q = next(&mut t).await;
531 match q.event {
532 SessionEventWire::QueryResult { id, value } => {
533 assert_eq!(id, 7);
534 assert_eq!(value["model"], "echo");
535 }
536 other => panic!("unexpected {other:?}"),
537 }
538
539 t.send(SessionCommand::Detach { client: t.client_id() })
541 .await
542 .unwrap();
543 let left = next(&mut t).await;
544 assert!(matches!(left.event, SessionEventWire::ClientLeft { client: ClientId(1) }));
545 assert!(t.handle().is_alive());
546
547 t.send(SessionCommand::End {
548 reason: EndReason::HostShutdown,
549 })
550 .await
551 .unwrap();
552 let ended = next(&mut t).await;
553 assert!(matches!(ended.event, SessionEventWire::Ended { .. }));
554 task.await.unwrap();
555 assert!(t.next_event().await.is_none());
556 assert!(matches!(
557 t.send(SessionCommand::Save).await,
558 Err(TransportError::Closed)
559 ));
560 }
561
562 #[tokio::test]
563 async fn two_local_clients_see_the_same_stream() {
564 let (handle, task) = SessionHandle::echo_for_test(SessionId::from("lt-2"));
565 let (mut a, _) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
566 .await
567 .unwrap();
568 let (mut b, snap_b) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Attach))
569 .await
570 .unwrap();
571 assert_ne!(a.client_id(), b.client_id());
572 assert_eq!(snap_b.clients.len(), 2);
573
574 a.send(SessionCommand::Submit {
575 text: "x".into(),
576 attachments: vec![],
577 })
578 .await
579 .unwrap();
580
581 let sa = collect(&mut a).await;
582 let sb = collect(&mut b).await;
583 assert_eq!(sa, sb);
584 assert_eq!(sa.len(), 3);
585
586 drop((a, b, handle));
587 task.await.unwrap();
588 }
589}