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