1use std::collections::HashMap;
31use std::sync::Arc;
32
33use anyhow::{Context, Result, bail};
34use kanade_shared::ipc::envelope::{
35 JSONRPC_VERSION, RpcMessage, RpcNotification, RpcRequest, RpcResponse, RpcResponsePayload,
36};
37use kanade_shared::ipc::handshake::{HandshakeParams, HandshakeResult, PROTOCOL_V1};
38use kanade_shared::ipc::jobs::{
39 JobsExecuteParams, JobsExecuteResult, JobsKillParams, JobsKillResult, JobsListParams,
40 JobsListResult,
41};
42use kanade_shared::ipc::method;
43use kanade_shared::ipc::notifications::{
44 NotificationsAckParams, NotificationsAckResult, NotificationsListParams,
45 NotificationsListResult, NotificationsSubscribeParams, NotificationsSubscribeResult,
46 NotificationsUnackParams, NotificationsUnackResult,
47};
48use kanade_shared::ipc::state::{StateSnapshot, StateSnapshotParams};
49use kanade_shared::ipc::support::{
50 SupportLockParams, SupportLockResult, SupportStatusParams, SupportStatusResult,
51 SupportUnlockParams, SupportUnlockResult,
52};
53use kanade_shared::ipc::system::{PingParams, PingResult};
54use serde::{Serialize, de::DeserializeOwned};
55use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, WriteHalf, split};
56#[cfg(target_os = "windows")]
57use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient};
58use tokio::sync::{Mutex, broadcast, oneshot, watch};
59use tracing::{debug, info, warn};
60
61#[cfg(target_os = "windows")]
62type KlpStream = NamedPipeClient;
63#[cfg(any(target_os = "linux", target_os = "macos"))]
64type KlpStream = tokio::net::UnixStream;
65
66#[derive(Debug)]
68pub struct ConnectionRejected {
69 pub retryable: bool,
70 pub detail: String,
71}
72
73impl std::fmt::Display for ConnectionRejected {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 write!(
76 f,
77 "KLP connection unauthorized: {} (retryable: {})",
78 self.detail, self.retryable
79 )
80 }
81}
82
83impl std::error::Error for ConnectionRejected {}
84
85#[derive(Debug)]
87pub enum ConnectError {
88 Rejected(ConnectionRejected),
89 Failed(anyhow::Error),
90}
91
92impl std::fmt::Display for ConnectError {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 match self {
95 Self::Rejected(rejection) => rejection.fmt(f),
96 Self::Failed(error) => write!(f, "{error:#}"),
97 }
98 }
99}
100
101impl std::error::Error for ConnectError {
102 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
103 Some(match self {
104 Self::Rejected(rejection) => rejection,
105 Self::Failed(error) => error.as_ref(),
106 })
107 }
108}
109
110impl From<anyhow::Error> for ConnectError {
111 fn from(error: anyhow::Error) -> Self {
112 match error.downcast::<ConnectionRejected>() {
113 Ok(rejection) => Self::Rejected(rejection),
114 Err(error) => Self::Failed(error),
115 }
116 }
117}
118
119impl From<ConnectionRejected> for ConnectError {
120 fn from(rejection: ConnectionRejected) -> Self {
121 Self::Rejected(rejection)
122 }
123}
124
125async fn retry_connect<F, Fut>(
128 mut connect: F,
129 initial: std::time::Duration,
130) -> std::result::Result<KlpClient, ConnectError>
131where
132 F: FnMut() -> Fut,
133 Fut: std::future::Future<Output = std::result::Result<KlpClient, ConnectError>>,
134{
135 let mut delay = initial;
136 loop {
137 match connect().await {
138 Ok(client) => return Ok(client),
139 Err(error) => {
140 if matches!(&error, ConnectError::Rejected(r) if !r.retryable) {
141 return Err(error);
142 }
143 warn!(%error, ?delay, "KLP connect failed; backing off");
144 tokio::time::sleep(delay).await;
145 delay = (delay * 2).min(std::time::Duration::from_secs(30));
146 }
147 }
148 }
149}
150
151const MAX_FRAME_BYTES: usize = 1024 * 1024;
153
154const NOTIFICATION_CAPACITY: usize = 256;
159
160const CLIENT_NAME: &str = "kanade-client";
163const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
164
165const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
174
175type Pending = Arc<std::sync::Mutex<HashMap<String, oneshot::Sender<RpcResponse>>>>;
184
185struct PendingGuard {
193 pending: Pending,
194 id: String,
195}
196
197impl Drop for PendingGuard {
198 fn drop(&mut self) {
199 self.pending.lock().unwrap().remove(&self.id);
200 }
201}
202
203#[derive(Clone)]
207pub struct KlpClient {
208 write: Arc<Mutex<WriteHalf<KlpStream>>>,
212 pending: Pending,
214 handshake: Arc<HandshakeResult>,
216 notifications: broadcast::Sender<RpcNotification>,
219 closed: watch::Receiver<bool>,
223}
224
225impl KlpClient {
226 pub async fn connect() -> std::result::Result<Self, ConnectError> {
231 #[cfg(target_os = "windows")]
232 {
233 use kanade_shared::ipc::transport::PIPE_NAME;
234 let pipe = ClientOptions::new()
235 .open(PIPE_NAME)
236 .with_context(|| format!("open Named Pipe {PIPE_NAME}"))?;
237 info!(pipe = PIPE_NAME, "KLP client: pipe connected");
238 Self::from_stream(pipe).await
239 }
240 #[cfg(any(target_os = "linux", target_os = "macos"))]
241 Self::connect_to(kanade_shared::ipc::transport::socket_path()).await
242 }
243
244 #[cfg(any(target_os = "linux", target_os = "macos"))]
246 pub async fn connect_to(
247 path: impl AsRef<std::path::Path>,
248 ) -> std::result::Result<Self, ConnectError> {
249 let pipe = KlpStream::connect(path.as_ref())
250 .await
251 .context("open KLP socket")?;
252 Self::from_stream(pipe).await
253 }
254
255 pub async fn connect_with_retry() -> std::result::Result<Self, ConnectError> {
256 retry_connect(Self::connect, std::time::Duration::from_secs(1)).await
257 }
258
259 #[cfg(any(target_os = "linux", target_os = "macos"))]
260 pub async fn connect_to_with_retry(
261 path: &std::path::Path,
262 ) -> std::result::Result<Self, ConnectError> {
263 retry_connect(|| Self::connect_to(path), std::time::Duration::from_secs(1)).await
264 }
265
266 async fn from_stream(mut pipe: KlpStream) -> std::result::Result<Self, ConnectError> {
267 let handshake = handshake(&mut pipe).await?;
272 info!(
273 agent_version = %handshake.agent_version,
274 user = %handshake.session.user,
275 session_id = handshake.session.session_id,
276 pc_id = %handshake.session.pc_id,
277 "KLP client: handshake complete",
278 );
279
280 let (read, write) = split(pipe);
281 let pending: Pending = Arc::new(std::sync::Mutex::new(HashMap::new()));
282 let (notifications, _) = broadcast::channel(NOTIFICATION_CAPACITY);
283 let (closed_tx, closed_rx) = watch::channel(false);
286 tokio::spawn(reader_loop(
287 read,
288 pending.clone(),
289 notifications.clone(),
290 closed_tx,
291 ));
292
293 Ok(Self {
294 write: Arc::new(Mutex::new(write)),
295 pending,
296 handshake: Arc::new(handshake),
297 notifications,
298 closed: closed_rx,
299 })
300 }
301
302 pub async fn wait_closed(&self) {
306 let mut rx = self.closed.clone();
307 let _ = rx.wait_for(|&closed| closed).await;
310 }
311
312 pub fn handshake(&self) -> Arc<HandshakeResult> {
316 self.handshake.clone()
317 }
318
319 pub fn subscribe(&self) -> broadcast::Receiver<RpcNotification> {
326 self.notifications.subscribe()
327 }
328
329 pub async fn request<P: Serialize, R: DeserializeOwned>(
336 &self,
337 method: &str,
338 params: &P,
339 ) -> Result<R> {
340 let id = uuid::Uuid::new_v4().to_string();
345 let req = RpcRequest::new(&id, method, params).context("encode KLP request")?;
346 let body = serde_json::to_vec(&req).context("serialise KLP request")?;
347
348 let rx = {
351 let (tx, rx) = oneshot::channel();
352 self.pending.lock().unwrap().insert(id.clone(), tx);
353 rx
354 };
355 let _guard = PendingGuard {
359 pending: self.pending.clone(),
360 id: id.clone(),
361 };
362
363 let write_result = {
367 let mut writer = self.write.lock().await;
368 write_frame(&mut *writer, &body).await
369 };
370 write_result.context("write frame")?;
371
372 let resp = await_response(rx, method, &id).await?;
381 decode_response::<R>(resp)
382 }
383
384 pub async fn ping(&self) -> Result<PingResult> {
386 self.request::<PingParams, PingResult>(method::SYSTEM_PING, &PingParams::default())
387 .await
388 }
389
390 pub async fn snapshot(&self) -> Result<StateSnapshot> {
393 self.request::<StateSnapshotParams, StateSnapshot>(
394 method::STATE_SNAPSHOT,
395 &StateSnapshotParams::default(),
396 )
397 .await
398 }
399
400 pub async fn jobs_list(&self, params: &JobsListParams) -> Result<JobsListResult> {
403 self.request::<JobsListParams, JobsListResult>(method::JOBS_LIST, params)
404 .await
405 }
406
407 pub async fn jobs_execute(&self, id: &str) -> Result<JobsExecuteResult> {
411 self.request::<JobsExecuteParams, JobsExecuteResult>(
412 method::JOBS_EXECUTE,
413 &JobsExecuteParams { id: id.to_string() },
414 )
415 .await
416 }
417
418 pub async fn jobs_kill(&self, run_id: &str) -> Result<JobsKillResult> {
421 self.request::<JobsKillParams, JobsKillResult>(
422 method::JOBS_KILL,
423 &JobsKillParams {
424 run_id: run_id.to_string(),
425 },
426 )
427 .await
428 }
429
430 pub async fn support_unlock(&self, code: &str) -> Result<SupportUnlockResult> {
434 self.request::<SupportUnlockParams, SupportUnlockResult>(
435 method::SUPPORT_UNLOCK,
436 &SupportUnlockParams {
437 code: code.to_string(),
438 },
439 )
440 .await
441 }
442
443 pub async fn support_lock(&self) -> Result<SupportLockResult> {
445 self.request::<SupportLockParams, SupportLockResult>(
446 method::SUPPORT_LOCK,
447 &SupportLockParams {},
448 )
449 .await
450 }
451
452 pub async fn support_status(&self) -> Result<SupportStatusResult> {
457 self.request::<SupportStatusParams, SupportStatusResult>(
458 method::SUPPORT_STATUS,
459 &SupportStatusParams {},
460 )
461 .await
462 }
463
464 pub async fn notifications_subscribe(&self) -> Result<NotificationsSubscribeResult> {
471 self.request::<NotificationsSubscribeParams, NotificationsSubscribeResult>(
472 method::NOTIFICATIONS_SUBSCRIBE,
473 &NotificationsSubscribeParams::default(),
474 )
475 .await
476 }
477
478 pub async fn notifications_list(
482 &self,
483 params: &NotificationsListParams,
484 ) -> Result<NotificationsListResult> {
485 self.request::<NotificationsListParams, NotificationsListResult>(
486 method::NOTIFICATIONS_LIST,
487 params,
488 )
489 .await
490 }
491
492 pub async fn notifications_ack(&self, id: &str) -> Result<NotificationsAckResult> {
498 self.request::<NotificationsAckParams, NotificationsAckResult>(
499 method::NOTIFICATIONS_ACK,
500 &NotificationsAckParams { id: id.to_string() },
501 )
502 .await
503 }
504
505 pub async fn notifications_unack(&self, id: &str) -> Result<NotificationsUnackResult> {
511 self.request::<NotificationsUnackParams, NotificationsUnackResult>(
512 method::NOTIFICATIONS_UNACK,
513 &NotificationsUnackParams { id: id.to_string() },
514 )
515 .await
516 }
517}
518
519async fn reader_loop<R: AsyncRead + Unpin>(
525 mut read: R,
526 pending: Pending,
527 notifications: broadcast::Sender<RpcNotification>,
528 closed_tx: watch::Sender<bool>,
529) {
530 loop {
531 let bytes = match read_frame(&mut read).await {
532 Ok(b) => b,
533 Err(e) => {
534 debug!(error = %e, "klp reader: pipe closed, exiting");
535 break;
536 }
537 };
538 let msg: RpcMessage = match serde_json::from_slice(&bytes) {
539 Ok(m) => m,
540 Err(e) => {
541 warn!(error = %e, "klp reader: undecodable frame, skipping");
542 continue;
543 }
544 };
545 match msg {
546 RpcMessage::Response(resp) => match resp.id.as_deref() {
547 Some(id) => {
548 let waiter = pending.lock().unwrap().remove(id);
551 match waiter {
552 Some(tx) => {
554 let _ = tx.send(resp);
555 }
556 None => {
557 debug!(id, "klp reader: response for unknown/expired request")
558 }
559 }
560 }
561 None => debug!("klp reader: response without id, ignoring"),
562 },
563 RpcMessage::Notification(notif) => {
564 let _ = notifications.send(notif);
567 }
568 RpcMessage::Request(_) => {
569 debug!("klp reader: agent sent a Request (unexpected), ignoring");
570 }
571 }
572 }
573 pending.lock().unwrap().clear();
576 let _ = closed_tx.send(true);
579}
580
581fn decode_response<R: DeserializeOwned>(resp: RpcResponse) -> Result<R> {
585 match resp.payload {
586 RpcResponsePayload::Ok { result } => {
587 serde_json::from_value(result).context("decode typed result")
588 }
589 RpcResponsePayload::Err { error } => {
590 let detail = error
591 .data
592 .as_ref()
593 .map(|d| d.detail.clone())
594 .unwrap_or_default();
595 bail!("KLP error {} ({}): {detail}", error.code, error.message);
596 }
597 }
598}
599
600async fn await_response(
612 rx: oneshot::Receiver<RpcResponse>,
613 method: &str,
614 id: &str,
615) -> Result<RpcResponse> {
616 match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
617 Ok(Ok(resp)) => Ok(resp),
618 Ok(Err(_)) => bail!("KLP connection closed before response (id {id})"),
619 Err(_) => bail!(
620 "KLP agent did not respond to {method} within {}s (id {id})",
621 REQUEST_TIMEOUT.as_secs(),
622 ),
623 }
624}
625
626async fn handshake<S: AsyncRead + AsyncWrite + Unpin>(pipe: &mut S) -> Result<HandshakeResult> {
627 let id = uuid::Uuid::new_v4().to_string();
629 let req = RpcRequest::new(
630 &id,
631 method::SYSTEM_HANDSHAKE,
632 &HandshakeParams {
633 client: CLIENT_NAME.to_string(),
634 client_version: CLIENT_VERSION.to_string(),
635 protocol: vec![PROTOCOL_V1],
636 features: vec![],
637 },
638 )
639 .context("encode handshake request")?;
640 let body = serde_json::to_vec(&req).context("serialise handshake request")?;
641 let exchange = async {
644 let (mut read, mut write) = split(pipe);
645 tokio::join!(write_frame(&mut write, &body), read_frame(&mut read))
646 };
647 let (sent, received) = tokio::time::timeout(REQUEST_TIMEOUT, exchange)
648 .await
649 .context("KLP handshake timed out")?;
650 let resp_bytes = received.context("read handshake response")?;
651 let value: serde_json::Value =
652 serde_json::from_slice(&resp_bytes).context("decode envelope")?;
653 if value.get("id").is_some_and(serde_json::Value::is_null)
654 && value["error"]["code"] == -32000
655 && value["error"]["data"]["kind"] == "Unauthorized"
656 {
657 return Err(ConnectionRejected {
658 retryable: value["error"]["data"]["retryable"]
659 .as_bool()
660 .unwrap_or(false),
661 detail: value["error"]["data"]["detail"]
662 .as_str()
663 .unwrap_or("Unauthorized")
664 .to_string(),
665 }
666 .into());
667 }
668 sent.context("write handshake")?;
669 let msg: RpcMessage = serde_json::from_slice(&resp_bytes).context("decode envelope")?;
670 let RpcMessage::Response(resp) = msg else {
671 bail!("expected handshake Response, got {msg:?}");
672 };
673 if resp.id.as_deref() != Some(id.as_str()) {
674 bail!(
675 "handshake response id mismatch: expected {id:?}, got {:?}",
676 resp.id
677 );
678 }
679 if resp.jsonrpc != JSONRPC_VERSION {
680 debug!(jsonrpc = %resp.jsonrpc, "unexpected jsonrpc field (proceeding)");
681 }
682 decode_response::<HandshakeResult>(resp)
683}
684
685async fn read_frame<R: AsyncRead + Unpin>(reader: &mut R) -> std::io::Result<Vec<u8>> {
688 let mut len_bytes = [0u8; 4];
689 reader.read_exact(&mut len_bytes).await?;
690 let len = u32::from_le_bytes(len_bytes) as usize;
691 if len > MAX_FRAME_BYTES {
692 return Err(std::io::Error::new(
693 std::io::ErrorKind::InvalidData,
694 format!("KLP frame {len} bytes exceeds 1 MiB cap"),
695 ));
696 }
697 let mut body = vec![0u8; len];
698 reader.read_exact(&mut body).await?;
699 Ok(body)
700}
701
702async fn write_frame<W: AsyncWrite + Unpin>(writer: &mut W, body: &[u8]) -> std::io::Result<()> {
703 if body.len() > MAX_FRAME_BYTES {
704 return Err(std::io::Error::new(
705 std::io::ErrorKind::InvalidData,
706 format!("KLP frame {} bytes exceeds 1 MiB cap", body.len()),
707 ));
708 }
709 let len = (body.len() as u32).to_le_bytes();
710 writer.write_all(&len).await?;
711 writer.write_all(body).await?;
712 writer.flush().await?;
713 Ok(())
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719 use kanade_shared::ipc::jobs::{JobProgress, RunStatus};
720
721 async fn push_frame<W: AsyncWrite + Unpin>(w: &mut W, value: &serde_json::Value) {
724 let body = serde_json::to_vec(value).unwrap();
725 write_frame(w, &body).await.unwrap();
726 }
727
728 #[tokio::test(start_paused = true)]
729 async fn silent_handshake_is_bounded() {
730 let (mut client, _server) = tokio::io::duplex(4096);
731 let error = handshake(&mut client).await.unwrap_err();
732 assert!(error.to_string().contains("timed out"));
733 }
734
735 #[tokio::test]
736 async fn admission_rejection_is_parsed_on_the_shared_transport_path() {
737 for flag in [Some(true), Some(false), None] {
738 let (mut client, mut server) = tokio::io::duplex(4096);
739 let send = async {
740 let mut value = serde_json::json!({"jsonrpc":"2.0", "id":null, "error":{
741 "code":-32000, "message":"Unauthorized",
742 "data":{"kind":"Unauthorized", "detail":"admission denied"}
743 }});
744 if let Some(flag) = flag {
745 value["error"]["data"]["retryable"] = flag.into();
746 }
747 push_frame(&mut server, &value).await;
748 };
749 let (result, ()) = tokio::join!(handshake(&mut client), send);
750 let error = result.unwrap_err();
751 let rejected = error.downcast_ref::<ConnectionRejected>().unwrap();
752 assert_eq!(rejected.retryable, flag.unwrap_or(false));
753 assert_eq!(rejected.detail, "admission denied");
754 }
755 }
756
757 #[tokio::test]
758 async fn rejection_survives_a_failed_handshake_write() {
759 struct FailedWriter(tokio::io::DuplexStream);
760 impl AsyncRead for FailedWriter {
761 fn poll_read(
762 mut self: std::pin::Pin<&mut Self>,
763 cx: &mut std::task::Context<'_>,
764 buf: &mut tokio::io::ReadBuf<'_>,
765 ) -> std::task::Poll<std::io::Result<()>> {
766 std::pin::Pin::new(&mut self.0).poll_read(cx, buf)
767 }
768 }
769 impl AsyncWrite for FailedWriter {
770 fn poll_write(
771 self: std::pin::Pin<&mut Self>,
772 _: &mut std::task::Context<'_>,
773 _: &[u8],
774 ) -> std::task::Poll<std::io::Result<usize>> {
775 std::task::Poll::Ready(Err(std::io::ErrorKind::BrokenPipe.into()))
776 }
777 fn poll_flush(
778 self: std::pin::Pin<&mut Self>,
779 _: &mut std::task::Context<'_>,
780 ) -> std::task::Poll<std::io::Result<()>> {
781 std::task::Poll::Ready(Ok(()))
782 }
783 fn poll_shutdown(
784 self: std::pin::Pin<&mut Self>,
785 _: &mut std::task::Context<'_>,
786 ) -> std::task::Poll<std::io::Result<()>> {
787 std::task::Poll::Ready(Ok(()))
788 }
789 }
790 let (client, mut server) = tokio::io::duplex(4096);
791 push_frame(
792 &mut server,
793 &serde_json::json!({"jsonrpc":"2.0", "id":null, "error":{
794 "code":-32000, "message":"Unauthorized",
795 "data":{"kind":"Unauthorized", "detail":"denied", "retryable":false}
796 }}),
797 )
798 .await;
799 let error = handshake(&mut FailedWriter(client)).await.unwrap_err();
800 assert!(
801 !error
802 .downcast_ref::<ConnectionRejected>()
803 .unwrap()
804 .retryable
805 );
806 }
807
808 #[tokio::test(start_paused = true)]
809 async fn reconnect_backoff_doubles_and_caps_at_thirty_seconds() {
810 let attempts = Arc::new(std::sync::Mutex::new(Vec::new()));
811 let recorded = attempts.clone();
812 let result = retry_connect(
813 move || {
814 let mut times = recorded.lock().unwrap();
815 times.push(tokio::time::Instant::now());
816 let count = times.len();
817 async move {
818 if count == 8 {
819 Err(ConnectionRejected {
820 retryable: false,
821 detail: "stop".into(),
822 }
823 .into())
824 } else {
825 Err(ConnectError::Failed(anyhow::anyhow!(
826 "temporarily unavailable"
827 )))
828 }
829 }
830 },
831 std::time::Duration::from_secs(1),
832 )
833 .await;
834 assert!(result.is_err());
835 let times = attempts.lock().unwrap();
836 let intervals: Vec<_> = times
837 .windows(2)
838 .map(|w| w[1].duration_since(w[0]).as_secs())
839 .collect();
840 assert_eq!(intervals, [1, 2, 4, 8, 16, 30, 30]);
841 }
842
843 #[tokio::test]
844 async fn reader_routes_response_to_pending_request() {
845 let (client_side, mut agent_side) = tokio::io::duplex(64 * 1024);
848 let pending: Pending = Arc::new(std::sync::Mutex::new(HashMap::new()));
849 let (notifications, _rx) = broadcast::channel(16);
850
851 let (tx, rx) = oneshot::channel();
853 pending.lock().unwrap().insert("req-1".into(), tx);
854
855 let (closed_tx, _closed_rx) = watch::channel(false);
856 tokio::spawn(reader_loop(
857 client_side,
858 pending.clone(),
859 notifications,
860 closed_tx,
861 ));
862
863 push_frame(
864 &mut agent_side,
865 &serde_json::json!({
866 "jsonrpc": "2.0",
867 "id": "req-1",
868 "result": { "run_id": "run-xyz" }
869 }),
870 )
871 .await;
872
873 let resp = rx.await.expect("reader should deliver the response");
874 assert_eq!(resp.id.as_deref(), Some("req-1"));
875 assert!(pending.lock().unwrap().is_empty(), "pending entry consumed");
876 }
877
878 #[tokio::test]
879 async fn reader_forwards_notification_to_subscribers() {
880 let (client_side, mut agent_side) = tokio::io::duplex(64 * 1024);
881 let pending: Pending = Arc::new(std::sync::Mutex::new(HashMap::new()));
882 let (notifications, mut sub) = broadcast::channel(16);
883
884 let (closed_tx, _closed_rx) = watch::channel(false);
885 tokio::spawn(reader_loop(client_side, pending, notifications, closed_tx));
886
887 let progress = JobProgress {
888 run_id: "run-1".into(),
889 status: RunStatus::Running,
890 stdout_chunk: None,
891 stderr_chunk: None,
892 exit_code: None,
893 };
894 let notif = RpcNotification::new(method::JOBS_PROGRESS, &progress).unwrap();
895 push_frame(&mut agent_side, &serde_json::to_value(¬if).unwrap()).await;
896
897 let got = sub.recv().await.expect("notification forwarded");
898 assert_eq!(got.method, method::JOBS_PROGRESS);
899 assert_eq!(got.params["run_id"], "run-1");
900 assert_eq!(got.params["status"], "running");
901 }
902
903 #[tokio::test]
904 async fn reader_exit_fails_pending_requests() {
905 let (client_side, agent_side) = tokio::io::duplex(64 * 1024);
908 let pending: Pending = Arc::new(std::sync::Mutex::new(HashMap::new()));
909 let (notifications, _rx) = broadcast::channel(16);
910
911 let (tx, rx) = oneshot::channel::<RpcResponse>();
912 pending.lock().unwrap().insert("req-orphan".into(), tx);
913
914 let (closed_tx, mut closed_rx) = watch::channel(false);
915 let handle = tokio::spawn(reader_loop(client_side, pending, notifications, closed_tx));
916
917 drop(agent_side);
920 handle.await.unwrap();
921
922 assert!(
923 rx.await.is_err(),
924 "pending request should be failed, not hung"
925 );
926 assert!(
928 closed_rx.wait_for(|&c| c).await.is_ok(),
929 "reader must signal closed on exit (#468 reconnect trigger)"
930 );
931 }
932
933 #[tokio::test(start_paused = true)]
934 async fn await_response_times_out_when_agent_silent() {
935 let (_tx, rx) = oneshot::channel::<RpcResponse>();
942
943 let err = await_response(rx, method::JOBS_EXECUTE, "req-wedged")
944 .await
945 .expect_err("silent agent must time out");
946 let msg = err.to_string();
947 assert!(msg.contains("did not respond"), "got: {msg}");
948 assert!(msg.contains(method::JOBS_EXECUTE), "names method: {msg}");
949 }
950
951 #[tokio::test(start_paused = true)]
952 async fn await_response_reports_closed_connection() {
953 let (tx, rx) = oneshot::channel::<RpcResponse>();
957 drop(tx);
958
959 let err = await_response(rx, method::SYSTEM_PING, "req-gone")
960 .await
961 .expect_err("dropped sender must error");
962 assert!(err.to_string().contains("closed"), "got: {err}");
963 }
964
965 #[tokio::test(start_paused = true)]
966 async fn await_response_delivers_reply() {
967 let (tx, rx) = oneshot::channel::<RpcResponse>();
968 let resp = RpcResponse {
969 jsonrpc: JSONRPC_VERSION.to_string(),
970 id: Some("req-ok".into()),
971 payload: RpcResponsePayload::Ok {
972 result: serde_json::json!({ "ok": true }),
973 },
974 };
975 tx.send(resp).unwrap();
976
977 let got = await_response(rx, method::SYSTEM_PING, "req-ok")
978 .await
979 .expect("delivered reply");
980 assert_eq!(got.id.as_deref(), Some("req-ok"));
981 }
982}
983
984#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
985mod unix_tests {
986 use super::*;
987 use std::time::Duration;
988 use tokio::net::UnixListener;
989
990 struct SocketDir(std::path::PathBuf);
991 impl SocketDir {
992 fn new() -> Self {
993 let path =
995 std::path::PathBuf::from("/tmp").join(format!("klp-{}", uuid::Uuid::new_v4()));
996 std::fs::create_dir(&path).unwrap();
997 Self(path)
998 }
999 fn socket(&self) -> std::path::PathBuf {
1000 self.0.join("s")
1001 }
1002 }
1003 impl Drop for SocketDir {
1004 fn drop(&mut self) {
1005 let _ = std::fs::remove_dir_all(&self.0);
1006 }
1007 }
1008
1009 async fn send(stream: &mut KlpStream, value: serde_json::Value) {
1010 write_frame(stream, &serde_json::to_vec(&value).unwrap())
1011 .await
1012 .unwrap();
1013 }
1014 async fn request(stream: &mut KlpStream) -> serde_json::Value {
1015 serde_json::from_slice(&read_frame(stream).await.unwrap()).unwrap()
1016 }
1017 async fn accept_handshake(listener: &UnixListener) -> KlpStream {
1018 let (mut stream, _) = listener.accept().await.unwrap();
1019 let req = request(&mut stream).await;
1020 assert_eq!(req["method"], method::SYSTEM_HANDSHAKE);
1021 assert_eq!(req["params"]["client"], CLIENT_NAME);
1022 assert_eq!(req["params"]["protocol"], serde_json::json!([1]));
1023 send(
1024 &mut stream,
1025 serde_json::json!({
1026 "jsonrpc":"2.0", "id":req["id"], "result": {
1027 "protocol":1, "agent_version":"test", "features":[],
1028 "session":{"user":"test", "session_id":1000, "pc_id":"test"}
1029 }
1030 }),
1031 )
1032 .await;
1033 stream
1034 }
1035 fn rejection(retryable: Option<bool>) -> serde_json::Value {
1036 let mut value = serde_json::json!({"jsonrpc":"2.0", "id":null, "error":{
1037 "code":-32000, "message":"Unauthorized",
1038 "data":{"kind":"Unauthorized", "detail":"test admission rejected"}
1039 }});
1040 if let Some(flag) = retryable {
1041 value["error"]["data"]["retryable"] = flag.into();
1042 }
1043 value
1044 }
1045
1046 #[tokio::test]
1047 async fn handshake_request_idle_push_and_restart() {
1048 let dir = SocketDir::new();
1049 let path = dir.socket();
1050 let listener = UnixListener::bind(&path).unwrap();
1051 let (client, mut server) =
1052 tokio::join!(KlpClient::connect_to(&path), accept_handshake(&listener));
1053 let client = client.unwrap();
1054 let mut pushes = client.subscribe();
1055 send(&mut server, serde_json::json!({"jsonrpc":"2.0", "method":"state.changed", "params":{"online":true}})).await;
1056 let push = tokio::time::timeout(Duration::from_secs(2), pushes.recv())
1057 .await
1058 .unwrap()
1059 .unwrap();
1060 assert_eq!(push.method, "state.changed");
1061 let response = async {
1062 let req = request(&mut server).await;
1063 assert_eq!(req["method"], "test.echo");
1064 send(&mut server, serde_json::json!({"jsonrpc":"2.0", "method":"jobs.progress", "params":{"run_id":"in-flight", "status":"running"}})).await;
1066 send(
1067 &mut server,
1068 serde_json::json!({"jsonrpc":"2.0", "id":req["id"], "result":42}),
1069 )
1070 .await;
1071 };
1072 let (result, ()) = tokio::time::timeout(Duration::from_secs(2), async {
1073 tokio::join!(client.request::<_, u32>("test.echo", &()), response)
1074 })
1075 .await
1076 .expect("interleaved notification must not stall the response");
1077 assert_eq!(result.unwrap(), 42);
1078 let push = tokio::time::timeout(Duration::from_secs(2), pushes.recv())
1079 .await
1080 .unwrap()
1081 .unwrap();
1082 assert_eq!(push.method, "jobs.progress");
1083 assert_eq!(push.params["run_id"], "in-flight");
1084 let response = async {
1086 let req = request(&mut server).await;
1087 let mut value = rejection(Some(false));
1088 value["id"] = req["id"].clone();
1089 send(&mut server, value).await;
1090 };
1091 let (result, ()) = tokio::join!(client.request::<_, u32>("test.denied", &()), response);
1092 assert!(result.unwrap_err().to_string().contains("Unauthorized"));
1093
1094 let lost = client.request::<_, u32>("test.pending", &());
1095 let shutdown = async {
1096 let req = request(&mut server).await;
1097 assert_eq!(req["method"], "test.pending");
1098 drop(server);
1099 };
1100 let (result, ()) = tokio::join!(lost, shutdown);
1101 assert!(result.unwrap_err().to_string().contains("closed"));
1102 tokio::time::timeout(Duration::from_secs(2), client.wait_closed())
1103 .await
1104 .unwrap();
1105 drop(listener);
1106 std::fs::remove_file(&path).unwrap();
1107 let restart = async {
1108 tokio::time::sleep(Duration::from_millis(30)).await;
1109 let listener = UnixListener::bind(&path).unwrap();
1110 let mut stream = accept_handshake(&listener).await;
1111 assert!(
1113 tokio::time::timeout(Duration::from_millis(30), read_frame(&mut stream))
1114 .await
1115 .is_err()
1116 );
1117 stream
1118 };
1119 let (new_client, _server) = tokio::join!(
1120 retry_connect(|| KlpClient::connect_to(&path), Duration::from_millis(10)),
1121 restart
1122 );
1123 assert_eq!(new_client.unwrap().handshake().protocol, 1);
1124 }
1125
1126 #[tokio::test]
1127 async fn permanent_and_missing_retryable_stop_after_one_accept() {
1128 for flag in [Some(false), None] {
1129 let dir = SocketDir::new();
1130 let path = dir.socket();
1131 let listener = UnixListener::bind(&path).unwrap();
1132 let server = async {
1133 let (mut stream, _) = listener.accept().await.unwrap();
1134 send(&mut stream, rejection(flag)).await;
1136 drop(stream);
1137 assert!(
1138 tokio::time::timeout(Duration::from_millis(50), listener.accept())
1139 .await
1140 .is_err()
1141 );
1142 };
1143 let (result, ()) = tokio::join!(
1144 retry_connect(|| KlpClient::connect_to(&path), Duration::from_millis(5)),
1145 server
1146 );
1147 let error = result.err().expect("must stop");
1148 assert!(error.to_string().contains("test admission rejected"));
1149 let ConnectError::Rejected(rejected) = error else {
1150 panic!("expected an authorization rejection");
1151 };
1152 assert!(!rejected.retryable);
1153 assert_eq!(rejected.detail, "test admission rejected");
1154 }
1155 }
1156
1157 #[tokio::test]
1158 async fn missing_socket_is_a_connection_failure() {
1159 let dir = SocketDir::new();
1160 let error = KlpClient::connect_to(dir.socket()).await.err().unwrap();
1161 let ConnectError::Failed(error) = error else {
1162 panic!("missing socket must be a connection failure");
1163 };
1164 assert_eq!(
1165 error.downcast_ref::<std::io::Error>().unwrap().kind(),
1166 std::io::ErrorKind::NotFound
1167 );
1168 }
1169
1170 #[tokio::test]
1171 async fn retryable_rejection_retries_and_handshakes() {
1172 let dir = SocketDir::new();
1173 let path = dir.socket();
1174 let listener = UnixListener::bind(&path).unwrap();
1175 let server = async {
1176 let (mut stream, _) = listener.accept().await.unwrap();
1177 send(&mut stream, rejection(Some(true))).await;
1178 drop(stream);
1179 accept_handshake(&listener).await
1180 };
1181 let (result, _server) = tokio::join!(
1182 retry_connect(|| KlpClient::connect_to(&path), Duration::from_millis(5)),
1183 server
1184 );
1185 assert_eq!(result.unwrap().handshake().protocol, 1);
1186 }
1187}