1use std::sync::Arc;
8
9use tokio::sync::broadcast;
10
11use super::handle::SessionHandle;
12use super::types::{
13 AttachMode, AttachSnapshot, ClientId, ClientMeta, Envelope, SessionCommand, SessionEventWire,
14 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}
42
43#[async_trait::async_trait]
44pub trait ClientTransport: Send {
45 fn session_id(&self) -> &SessionId;
46 fn meta(&self) -> &SessionMeta;
47 async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError>;
49 async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError>;
52 async fn next_event(&mut self) -> Option<Envelope>;
54 fn view(&self) -> Arc<RuntimeView>;
57 fn client_id(&self) -> ClientId;
59 fn mode(&self) -> AttachMode;
61 fn input_owner(&self) -> Option<ClientId>;
63 async fn reconnect(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError>;
66 async fn reconnect_once(&mut self, mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
68 self.reconnect(mode).await
69 }
70 fn is_reload_pending(&self) -> bool {
73 false
74 }
75 fn daemon_pid(&self) -> u32 {
77 0
78 }
79}
80
81pub const ATTACH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
83pub const ATTACH_TIMEOUT_PARKED: std::time::Duration = std::time::Duration::from_secs(25);
85
86pub struct LocalTransport {
87 handle: SessionHandle,
88 rx: broadcast::Receiver<Envelope>,
89 client: ClientId,
90 meta: SessionMeta,
91 mode: AttachMode,
92 input_owner: Option<ClientId>,
93 ended: bool,
95}
96
97impl LocalTransport {
98 pub async fn attach(
100 handle: SessionHandle,
101 meta: ClientMeta,
102 ) -> Result<(Self, AttachSnapshot), TransportError> {
103 Self::attach_with(handle, meta, AttachMode::Mirror).await
104 }
105
106 pub async fn attach_with(
109 handle: SessionHandle,
110 meta: ClientMeta,
111 mode: AttachMode,
112 ) -> Result<(Self, AttachSnapshot), TransportError> {
113 let mut rx = handle.subscribe();
117 handle
118 .send(SessionCommand::Attach {
119 client: meta,
120 mode,
121 })
122 .await?;
123
124 let wait = async {
125 loop {
126 match rx.recv().await {
127 Ok(env) => match env.event {
128 SessionEventWire::Attached { client, snapshot } => {
129 return Ok((client, snapshot));
130 }
131 SessionEventWire::AttachRefused { message } => {
132 return Err(TransportError::Refused(message));
133 }
134 _ => {}
135 },
136 Err(broadcast::error::RecvError::Lagged(_)) => continue,
137 Err(broadcast::error::RecvError::Closed) => {
138 return Err(TransportError::Closed)
139 }
140 }
141 }
142 };
143 let timeout = if handle.lifecycle() == super::types::SessionLifecycle::Parked {
144 ATTACH_TIMEOUT_PARKED
145 } else {
146 ATTACH_TIMEOUT
147 };
148 let (client, snapshot) = match tokio::time::timeout(timeout, wait).await {
149 Ok(r) => r?,
150 Err(_) => return Err(TransportError::Protocol("attach timed out".into())),
151 };
152 let meta = snapshot.meta.clone();
153 Ok((
154 Self {
155 handle,
156 rx,
157 client,
158 meta,
159 mode,
160 input_owner: snapshot.input_owner,
161 ended: false,
162 },
163 snapshot,
164 ))
165 }
166
167 pub fn handle(&self) -> &SessionHandle {
168 &self.handle
169 }
170}
171
172#[async_trait::async_trait]
173impl ClientTransport for LocalTransport {
174 fn session_id(&self) -> &SessionId {
175 &self.handle.id
176 }
177
178 fn meta(&self) -> &SessionMeta {
179 &self.meta
180 }
181
182 async fn send(&self, cmd: SessionCommand) -> Result<(), TransportError> {
183 self.handle.send(cmd).await
184 }
185
186 async fn send_from_self(&self, cmd: SessionCommand) -> Result<(), TransportError> {
187 self.handle.send_from(self.client, cmd).await
188 }
189
190 async fn next_event(&mut self) -> Option<Envelope> {
191 if self.ended {
192 return None;
193 }
194 let recv = tokio::select! {
198 biased;
199 r = self.rx.recv() => r,
200 _ = self.handle.closed() => match self.rx.try_recv() {
201 Ok(env) => Ok(env),
202 Err(broadcast::error::TryRecvError::Lagged(n)) => {
203 Err(broadcast::error::RecvError::Lagged(n))
204 }
205 Err(_) => Err(broadcast::error::RecvError::Closed),
206 },
207 };
208 match recv {
209 Ok(env) => {
210 match &env.event {
211 SessionEventWire::Ended { .. } => self.ended = true,
212 SessionEventWire::InputOwnerChanged { to, .. } => self.input_owner = *to,
213 _ => {}
214 }
215 Some(env)
216 }
217 Err(broadcast::error::RecvError::Lagged(n)) => {
218 tracing::warn!(session = %self.handle.id, dropped = n, "session event stream lagged");
220 Some(Envelope {
221 session_id: self.handle.id.clone(),
222 seq: u64::MAX,
223 ts: chrono::Utc::now(),
224 event: SessionEventWire::SystemNotice(format!(
225 "event stream lagged; {n} dropped"
226 )),
227 })
228 }
229 Err(broadcast::error::RecvError::Closed) => {
230 self.ended = true;
231 None
232 }
233 }
234 }
235
236 fn view(&self) -> Arc<RuntimeView> {
237 Arc::clone(&self.handle.view())
238 }
239
240 fn client_id(&self) -> ClientId {
241 self.client
242 }
243
244 fn mode(&self) -> AttachMode {
245 self.mode
246 }
247
248 fn input_owner(&self) -> Option<ClientId> {
249 self.input_owner
250 }
251
252 async fn reconnect(&mut self, _mode: AttachMode) -> Result<AttachSnapshot, TransportError> {
253 Err(TransportError::Unsupported("reconnect: in-process transport"))
254 }
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use crate::session::types::*;
261 use crate::{LlmEvent, SessionEvent, StreamEvent};
262
263 async fn next(t: &mut LocalTransport) -> Envelope {
264 tokio::time::timeout(std::time::Duration::from_secs(2), t.next_event())
265 .await
266 .expect("timely")
267 .expect("open")
268 }
269
270 async fn collect(t: &mut LocalTransport) -> Vec<String> {
272 let mut out = Vec::new();
273 loop {
274 let env = next(t).await;
275 let done = matches!(env.event, SessionEventWire::Conversation(_));
276 if matches!(env.event, SessionEventWire::Stream(_)) {
277 out.push(format!("{:?}", env.event));
278 }
279 if done {
280 break;
281 }
282 }
283 out
284 }
285
286 #[tokio::test]
287 async fn local_transport_round_trip_over_echo() {
288 let (handle, task) = SessionHandle::echo_for_test(SessionId::from("lt-1"));
289 let (mut t, snap) = LocalTransport::attach(handle, ClientMeta::new(ClientKind::Test))
290 .await
291 .unwrap();
292 assert_eq!(t.session_id().as_str(), "lt-1");
293 assert_eq!(t.client_id(), ClientId(1));
294 assert_eq!(t.mode(), AttachMode::Mirror);
295 assert!(t.input_owner().is_none());
296 assert!(matches!(t.reconnect(AttachMode::Mirror).await, Err(TransportError::Unsupported(_))));
297 assert_eq!(snap.meta.model, "echo");
298 assert_eq!(t.view().model, "echo");
299 assert!(snap.conversation.api_messages.is_empty());
300
301 let joined = next(&mut t).await;
303 assert!(matches!(
304 joined.event,
305 SessionEventWire::ClientJoined {
306 client: ClientId(1),
307 kind: ClientKind::Test
308 }
309 ));
310
311 t.send(SessionCommand::Submit {
312 text: "ping".into(),
313 attachments: vec![],
314 })
315 .await
316 .unwrap();
317
318 let mut seen = Vec::new();
319 loop {
320 let env = next(&mut t).await;
321 let done = matches!(env.event, SessionEventWire::Conversation(_));
322 seen.push(env);
323 if done {
324 break;
325 }
326 }
327 assert!(matches!(seen[0].event, SessionEventWire::TurnStarted { .. }));
328 match &seen[1].event {
329 SessionEventWire::Stream(StreamEvent::Llm(LlmEvent::Text(t))) => assert_eq!(t, "ping"),
330 other => panic!("unexpected {other:?}"),
331 }
332 assert!(matches!(
333 seen[3].event,
334 SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done))
335 ));
336 let seqs: Vec<u64> = seen.iter().map(|e| e.seq).collect();
337 assert!(seqs.windows(2).all(|w| w[1] == w[0] + 1), "gapless: {seqs:?}");
338
339 t.send(SessionCommand::Query {
341 id: 7,
342 query: SessionQuery::View,
343 })
344 .await
345 .unwrap();
346 let q = next(&mut t).await;
347 match q.event {
348 SessionEventWire::QueryResult { id, value } => {
349 assert_eq!(id, 7);
350 assert_eq!(value["model"], "echo");
351 }
352 other => panic!("unexpected {other:?}"),
353 }
354
355 t.send(SessionCommand::Detach { client: t.client_id() })
357 .await
358 .unwrap();
359 let left = next(&mut t).await;
360 assert!(matches!(left.event, SessionEventWire::ClientLeft { client: ClientId(1) }));
361 assert!(t.handle().is_alive());
362
363 t.send(SessionCommand::End {
364 reason: EndReason::HostShutdown,
365 })
366 .await
367 .unwrap();
368 let ended = next(&mut t).await;
369 assert!(matches!(ended.event, SessionEventWire::Ended { .. }));
370 task.await.unwrap();
371 assert!(t.next_event().await.is_none());
372 assert!(matches!(
373 t.send(SessionCommand::Save).await,
374 Err(TransportError::Closed)
375 ));
376 }
377
378 #[tokio::test]
379 async fn two_local_clients_see_the_same_stream() {
380 let (handle, task) = SessionHandle::echo_for_test(SessionId::from("lt-2"));
381 let (mut a, _) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Tui))
382 .await
383 .unwrap();
384 let (mut b, snap_b) = LocalTransport::attach(handle.clone(), ClientMeta::new(ClientKind::Attach))
385 .await
386 .unwrap();
387 assert_ne!(a.client_id(), b.client_id());
388 assert_eq!(snap_b.clients.len(), 2);
389
390 a.send(SessionCommand::Submit {
391 text: "x".into(),
392 attachments: vec![],
393 })
394 .await
395 .unwrap();
396
397 let sa = collect(&mut a).await;
398 let sb = collect(&mut b).await;
399 assert_eq!(sa, sb);
400 assert_eq!(sa.len(), 3);
401
402 drop((a, b, handle));
403 task.await.unwrap();
404 }
405}