Skip to main content

kanade_client/
klp_client.rs

1//! Client-side KLP transport — Named Pipe / Unix socket + framed JSON-RPC.
2//!
3//! Owned by the Tauri runtime (one [`KlpClient`] per app instance,
4//! shared via `tauri::State`). On startup, [`KlpClient::connect`]
5//! opens the agent transport, runs the
6//! SPEC §2.12.6 handshake, then **splits the stream** into a write half
7//! (guarded by a mutex for serialised sends) and a read half owned by
8//! a permanent reader task.
9//!
10//! # Reader-task demultiplex
11//!
12//! The agent interleaves two kinds of agent→client frames on one pipe
13//! (SPEC §2.12.3): correlated `Response`s and unsolicited push
14//! `Notification`s (`jobs.progress`, `state.changed`, …). The reader
15//! task reads every frame and routes it:
16//!
17//! - `Response` → the matching in-flight [`KlpClient::request`] call,
18//!   looked up by id in the `pending` map and delivered over a
19//!   oneshot.
20//! - `Notification` → the `notifications` broadcast, which the Tauri
21//!   layer forwards to the WebView as an event (see
22//!   [`KlpClient::subscribe`]).
23//!
24//! This split is what lets pushes arrive while the app is idle (the
25//! old inline-read model only read the pipe during a request, so a
26//! `jobs.progress` between requests was invisible) and lets concurrent
27//! requests multiplex over the one pipe (each correlates its own id
28//! instead of assuming the next frame is its reply).
29
30use 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/// Connect-time authorization failure. Missing retryability fails closed.
67#[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/// Why opening a KLP connection or completing its handshake failed.
86#[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
125/// Retry transient connect errors with the SPEC §2.12.10 backoff.
126/// Returns permanent rejection to the supervisor instead of looping forever.
127async 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
151/// SPEC §2.12.2 — 4-byte LE length prefix; 1 MiB cap.
152const MAX_FRAME_BYTES: usize = 1024 * 1024;
153
154/// Bounded capacity of the push-notification broadcast. A slow
155/// WebView subscriber that falls this far behind drops the oldest
156/// notifications (broadcast lag) rather than stalling the reader task
157/// — progress UX, not a transactional stream.
158const NOTIFICATION_CAPACITY: usize = 256;
159
160/// Client-side product identifier sent in the handshake. Surfaced
161/// in the agent's audit log + tracing spans (SPEC §2.12.12).
162const CLIENT_NAME: &str = "kanade-client";
163const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
164
165/// Per-request deadline for [`KlpClient::request`] (#469). A dropped
166/// connection already unblocks a waiter (the reader clears `pending`,
167/// so the oneshot sender drops and the await errors), so this only
168/// bounds the "connection alive, agent silent on this id" case — a
169/// wedged agent-side handler. Generous on purpose: every v1 handler
170/// replies promptly (`jobs.execute` returns a `run_id` immediately and
171/// streams progress as async pushes), so no legitimate request blocks
172/// on long work; 30 s is far past any real reply latency.
173const REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
174
175/// In-flight request registry: request id → the oneshot the reader
176/// task fulfils when the matching `Response` arrives.
177///
178/// `std::sync::Mutex` (not the tokio one used for `write`): it only
179/// ever guards fast in-memory `HashMap` ops — never held across an
180/// await — so a sync mutex is both correct and lets [`PendingGuard`]
181/// clean up a cancelled request from inside `Drop` (a tokio mutex
182/// can't be locked synchronously there).
183type Pending = Arc<std::sync::Mutex<HashMap<String, oneshot::Sender<RpcResponse>>>>;
184
185/// RAII cleanup for one in-flight request's `pending` entry. Removes
186/// the id on drop — whether the request completed, errored, or its
187/// future was cancelled (dropped before the response arrived) — so a
188/// cancelled or write-failed request can't leak its `oneshot::Sender`
189/// in the map until the connection closes. A successful response was
190/// already removed by the reader, so the drop-time removal is then a
191/// harmless no-op.
192struct 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/// Shared KLP connection. Cheap to clone (everything behind `Arc`),
204/// so each Tauri command clones it out of `AppState` and runs its
205/// round-trip without holding the app-state mutex across the await.
206#[derive(Clone)]
207pub struct KlpClient {
208    /// Write half of the split pipe. Mutex-guarded so concurrent
209    /// requests serialise their frame writes (a half-written frame
210    /// would desync the agent's reader).
211    write: Arc<Mutex<WriteHalf<KlpStream>>>,
212    /// In-flight requests awaiting a correlated response.
213    pending: Pending,
214    /// Cached handshake result (one-shot per connection).
215    handshake: Arc<HandshakeResult>,
216    /// Push-notification fan-out. The reader task is the sole sender;
217    /// the Tauri layer subscribes and forwards to the WebView.
218    notifications: broadcast::Sender<RpcNotification>,
219    /// Flips to `true` when the reader task exits (pipe closed — agent
220    /// restart / crash). The Tauri supervisor awaits this via
221    /// [`KlpClient::wait_closed`] to drive reconnection (#468).
222    closed: watch::Receiver<bool>,
223}
224
225impl KlpClient {
226    /// Open the pipe, run the SPEC §2.12.6 handshake, split the pipe,
227    /// and spawn the reader task. Bubbles up the underlying error so
228    /// the Tauri `setup` callback can surface a clear "agent not
229    /// running" banner if the pipe isn't there yet.
230    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    /// Connect to an explicit socket endpoint (tests / development).
245    #[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        // Handshake inline on the un-split pipe — it's the one
268        // strictly-ordered request/response exchange (must complete
269        // before any push can arrive), so the reader task only takes
270        // over afterwards.
271        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        // `closed` flips true when the reader task exits, so the
284        // supervisor can reconnect (#468).
285        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    /// Resolve once the connection has closed (the reader task exited —
303    /// the agent's pipe went away). The Tauri supervisor awaits this to
304    /// trigger reconnection. Returns immediately if already closed.
305    pub async fn wait_closed(&self) {
306        let mut rx = self.closed.clone();
307        // `wait_for` returns Ok when the predicate holds; Err means the
308        // sender dropped (reader gone) — both mean "closed".
309        let _ = rx.wait_for(|&closed| closed).await;
310    }
311
312    /// Cached handshake result. Returned to the UI on every
313    /// `get_handshake` invoke — handshake is one-shot per
314    /// connection so the cache is the source of truth.
315    pub fn handshake(&self) -> Arc<HandshakeResult> {
316        self.handshake.clone()
317    }
318
319    /// Subscribe to agent→client push notifications (`jobs.progress`,
320    /// `state.changed`, …). The Tauri layer drains this and re-emits
321    /// each notification to the WebView as a `klp://notification`
322    /// event. A subscriber only receives notifications sent *after*
323    /// it subscribes (broadcast semantics) — subscribe right after
324    /// `connect` so no early progress is missed.
325    pub fn subscribe(&self) -> broadcast::Receiver<RpcNotification> {
326        self.notifications.subscribe()
327    }
328
329    /// Round-trip one request through the pipe. Mints a UUID id,
330    /// registers a pending waiter, writes the frame, and awaits the
331    /// reader task's correlated response.
332    ///
333    /// `params` is the typed request payload; `R` is the per-method
334    /// result struct (e.g. [`PingResult`]).
335    pub async fn request<P: Serialize, R: DeserializeOwned>(
336        &self,
337        method: &str,
338        params: &P,
339    ) -> Result<R> {
340        // SPEC §2.12.3 prefers UUID v7 (time-sortable, easier log
341        // correlation); the workspace's `uuid` feature set doesn't
342        // enable v7 today so we use v4. Switch when the workspace pin
343        // grows the `v7` feature.
344        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        // Register the waiter BEFORE writing so a fast reply can't
349        // arrive before we're listening for it.
350        let rx = {
351            let (tx, rx) = oneshot::channel();
352            self.pending.lock().unwrap().insert(id.clone(), tx);
353            rx
354        };
355        // From here on, any early return / cancellation removes the
356        // pending entry (no leaked sender). The reader removes it on a
357        // successful delivery, making this drop a no-op then.
358        let _guard = PendingGuard {
359            pending: self.pending.clone(),
360            id: id.clone(),
361        };
362
363        // Write under the (async) write lock, then release it before
364        // awaiting the response so the next request can write while
365        // this one waits.
366        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        // The reader task delivers the id-matched response (or drops
373        // the sender on disconnect, which surfaces as RecvError).
374        //
375        // Bounded by REQUEST_TIMEOUT (#469): a live-but-silent agent
376        // (wedged handler that never replies to THIS id) would otherwise
377        // await forever. On timeout, `_guard` drops the pending entry so
378        // a late reply is discarded rather than delivered to a gone
379        // waiter. A disconnect still unblocks faster via RecvError.
380        let resp = await_response(rx, method, &id).await?;
381        decode_response::<R>(resp)
382    }
383
384    /// Convenience wrapper for `system.ping`.
385    pub async fn ping(&self) -> Result<PingResult> {
386        self.request::<PingParams, PingResult>(method::SYSTEM_PING, &PingParams::default())
387            .await
388    }
389
390    /// `state.snapshot` — the full endpoint health bundle the Health
391    /// tab renders (#290).
392    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    /// `jobs.list` — the user-invokable job catalog for the three job
401    /// tabs (#291). `params.category` narrows to one tab.
402    pub async fn jobs_list(&self, params: &JobsListParams) -> Result<JobsListResult> {
403        self.request::<JobsListParams, JobsListResult>(method::JOBS_LIST, params)
404            .await
405    }
406
407    /// `jobs.execute` — run a user-invokable job by id; returns the
408    /// `run_id` whose `jobs.progress` pushes arrive via
409    /// [`KlpClient::subscribe`] (#291).
410    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    /// `jobs.kill` — request termination of a run this connection
419    /// started (#291).
420    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    /// `support.unlock` — redeem an operator-issued support code, revealing
431    /// this OS user's `client.unlock`-gated jobs until the grant expires.
432    /// A wrong code comes back as an error, never an empty grant list.
433    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    /// `support.lock` — end the support session now, dropping every grant.
444    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    /// `support.status` — the grants this OS user currently holds. Called on
453    /// every (re)connect: grants outlive a single KLP connection, so a client
454    /// that reconnected mid-session restores its banner from this rather than
455    /// asking the desk to re-type the code.
456    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    /// `notifications.subscribe` — ask the agent to start streaming
465    /// `notifications.new` pushes on this connection (Phase E, #102).
466    /// Until it lands the agent fans nothing out to this client, so the
467    /// WebView calls it once per connection (re-called on reconnect);
468    /// the pushes then arrive via [`KlpClient::subscribe`] like any
469    /// other agent→client notification.
470    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    /// `notifications.list` — this user's notification history (#102),
479    /// scoped to the caller's OS SID agent-side. `params.filter` selects
480    /// unread-only vs all; `params.cursor` pages.
481    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    /// `notifications.ack` — mark a notification read for the caller's
493    /// OS user (#102). The agent derives the SID from the connection,
494    /// never the payload, then persists `notifications_read` KV and
495    /// publishes the `events.notifications.acked.>` event the SPA's
496    /// ack-status view reads (#604).
497    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    /// `notifications.unack` — retract the caller's prior ack (the
506    /// read↔unread toggle). Inverse of [`Self::notifications_ack`]: the
507    /// agent deletes the `notifications_read` KV entry and publishes
508    /// `events.notifications.unacked.>` so the SPA roster flips back to
509    /// "未確認" while the audit log keeps the original confirmation.
510    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
519/// Permanent reader task: demultiplex every agent→client frame.
520/// Generic over the read half so it's unit-testable with an in-memory
521/// duplex pipe. Exits when the pipe closes / errors; on exit it drops
522/// every pending sender so in-flight `request` calls fail fast instead
523/// of hanging forever.
524async 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                    // Remove (releasing the lock) BEFORE sending so the
549                    // sync mutex isn't held across the oneshot send.
550                    let waiter = pending.lock().unwrap().remove(id);
551                    match waiter {
552                        // Receiver dropped (request cancelled) → ignore.
553                        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                // Err only when there are no live subscribers — fine,
565                // a push with nobody listening is just dropped.
566                let _ = notifications.send(notif);
567            }
568            RpcMessage::Request(_) => {
569                debug!("klp reader: agent sent a Request (unexpected), ignoring");
570            }
571        }
572    }
573    // Connection gone: fail every in-flight request rather than leave
574    // it awaiting a oneshot that will never resolve.
575    pending.lock().unwrap().clear();
576    // Signal the supervisor to reconnect (#468). Ignore the error —
577    // a dropped receiver just means the KlpClient is already gone.
578    let _ = closed_tx.send(true);
579}
580
581/// Pull the typed result out of an [`RpcResponse`]; map error
582/// envelopes to `anyhow::Error` with the SPEC §2.12.9 detail
583/// preserved so the UI sees the same message the agent logged.
584fn 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
600/// Await the reader task's correlated response under the
601/// [`REQUEST_TIMEOUT`] deadline (#469). Three outcomes:
602///
603/// - reply delivered → `Ok(resp)`;
604/// - oneshot sender dropped (connection closed, reader cleared
605///   `pending`) → a "closed before response" error;
606/// - deadline elapsed (connection alive, agent silent on this id) →
607///   a "did not respond within Ns" error.
608///
609/// Split out of [`KlpClient::request`] so the timeout/closed branches
610/// are unit-testable without a live pipe.
611async 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    // Same v4-instead-of-v7 note as in `KlpClient::request`.
628    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    // Admission rejection can arrive before the agent reads our request.
642    // Read concurrently and prefer a rejection even if the write fails.
643    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
685// ---- Framing (mirror of `kanade_agent::klp::framing`) ----
686
687async 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    /// Encode a JSON value as a length-prefixed frame (mirror of the
722    /// agent writer, for tests).
723    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        // A duplex pipe stands in for the Named Pipe: we write agent
846        // frames into `agent_side`, the reader reads `client_side`.
847        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        // Pre-register a waiter for id "req-1", as `request` would.
852        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(&notif).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        // When the pipe closes, in-flight requests must error out
906        // (oneshot sender dropped) rather than hang forever.
907        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 the agent side → EOF → reader exits → clears pending +
918        // signals closed.
919        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        // The reader signalled the supervisor that the connection died.
927        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        // Hold the sender so the oneshot never resolves: the agent is
936        // alive but never replies to this id (#469). Under
937        // `start_paused = true` the runtime auto-advances the paused
938        // clock to the next pending deadline once this task is the only
939        // thing blocking, so `timeout`'s `Sleep` fires deterministically
940        // and instantly — no explicit `tokio::time::advance` needed.
941        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        // Sender dropped before any reply → the reader cleared `pending`
954        // on disconnect. This must surface as "closed", distinct from a
955        // timeout, and resolve immediately (no waiting out the deadline).
956        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            // Short even when macOS TMPDIR is long (sun_path is only 104 bytes).
994            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            // Interleave a push while this request is still awaiting its reply.
1065            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        // A regular Unauthorized response does not change connect policy.
1085        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            // The old pending request must never be replayed.
1112            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                // Refuse without reading, exactly as the agent admission path does.
1135                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}