Skip to main content

rightkit_daemon/
client.rs

1use crate::error::*;
2use crate::host_identity::{HostConnectError, HostExpectation, HostProcess};
3use crate::private::PrivateSecret;
4use crate::record::{try_read_record, ServiceRecord, PROTOCOL_MAJOR};
5use crate::suite::{Suite, SuiteError};
6use crate::transport::{connect, connect_expecting, Conn};
7use rightkit_sidecar::{read_json_line, write_json_line, FrameError};
8use serde_json::{json, Value};
9use std::io::{BufReader, Read, Write};
10use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
11use std::sync::{mpsc, Arc, Mutex};
12use std::time::{Duration, Instant};
13
14const LIMIT: usize = 16 * 1024 * 1024;
15
16/// Default per-request deadline (connect + hello, and each call's write + read).
17/// Override with [`Client::connect_with`] / [`Client::set_deadline`]; `None` disables it.
18pub const DEFAULT_DEADLINE: Duration = Duration::from_secs(120);
19
20/// Aborts the connection when the deadline passes; disarmed on drop. Aborting
21/// goes through the transport `shutdown`, which wakes a blocked Unix read/write
22/// and issues CancelIoEx on a Windows pipe.
23struct Watchdog {
24    fired: Arc<AtomicBool>,
25    _disarm: mpsc::Sender<()>,
26}
27
28impl Watchdog {
29    fn arm(after: Option<Duration>, shutdown: Arc<dyn Fn() + Send + Sync>) -> Watchdog {
30        let fired = Arc::new(AtomicBool::new(false));
31        let (tx, rx) = mpsc::channel::<()>();
32        if let Some(d) = after {
33            let f = fired.clone();
34            std::thread::spawn(move || {
35                if let Err(mpsc::RecvTimeoutError::Timeout) = rx.recv_timeout(d) {
36                    f.store(true, Ordering::SeqCst);
37                    shutdown();
38                }
39            });
40        }
41        Watchdog { fired, _disarm: tx }
42    }
43    fn fired(&self) -> bool {
44        self.fired.load(Ordering::SeqCst)
45    }
46}
47
48fn timed_out(what: &str, d: Option<Duration>) -> ServiceError {
49    ServiceError::new(
50        ERROR_TIMEOUT,
51        format!(
52            "{what} exceeded the {} ms deadline; connection aborted",
53            d.map(|d| d.as_millis()).unwrap_or(0)
54        ),
55    )
56}
57
58/// Lock `m`, giving up at `deadline` (`None` = wait without bound). Polls with a short sleep:
59/// `std::sync::Mutex` has no timed lock, and the wait is bounded by the deadline.
60fn lock_until<T>(m: &Mutex<T>, deadline: Option<Instant>) -> Option<std::sync::MutexGuard<'_, T>> {
61    use std::sync::TryLockError;
62    let Some(deadline) = deadline else {
63        return Some(m.lock().unwrap_or_else(|e| e.into_inner()));
64    };
65    let mut pause = Duration::from_micros(200);
66    loop {
67        match m.try_lock() {
68            Ok(g) => return Some(g),
69            Err(TryLockError::Poisoned(e)) => return Some(e.into_inner()),
70            Err(TryLockError::WouldBlock) => {}
71        }
72        let left = deadline.saturating_duration_since(Instant::now());
73        if left.is_zero() {
74            return None;
75        }
76        std::thread::sleep(pause.min(left));
77        pause = (pause * 2).min(Duration::from_millis(5));
78    }
79}
80
81struct Io {
82    reader: BufReader<Box<dyn Read + Send>>,
83    writer: Box<dyn Write + Send>,
84    buf: Vec<u8>,
85}
86
87/// What a client needs to find and call one service, including how much it trusts its
88/// environment. Pass it to [`Client::connect_expecting`] (verified host) or
89/// [`Client::connect_config`] (hardened root, unverified host).
90///
91/// The suite root is chosen like this:
92///
93/// * [`suite`](Self::suite) given: that suite, as is. With
94///   [`ignore_env_overrides`](Self::ignore_env_overrides) on, a suite that came from the
95///   environment ([`Suite::resolve`], see [`Suite::env_derived`]) is refused, so the switch cannot
96///   be defeated by passing `Suite::resolve()` in.
97/// * not given, `ignore_env_overrides` off: [`Suite::resolve`], which honours
98///   `RIGHTKIT_SUITE_ROOT`, `RIGHT_SUITE_DIR`, `ORTHIC_SHARED_DIR` and `HOME`/`XDG_DATA_HOME`/
99///   `LOCALAPPDATA`. For QA builds and isolated dev profiles.
100/// * not given, `ignore_env_overrides` on: [`Suite::per_user_default`], from the account database.
101///   For release builds. A launcher that sets environment variables cannot redirect the client.
102///
103/// A host that wants the same switch calls [`Suite::resolve_ignoring_env_overrides`]; the host
104/// functions themselves take the suite as an argument and read no environment variable.
105#[derive(Debug, Clone)]
106pub struct ClientConfig {
107    service: String,
108    app: String,
109    suite: Option<Suite>,
110    deadline: Option<Duration>,
111    ignore_env_overrides: bool,
112    secret: Option<PrivateSecret>,
113}
114
115impl ClientConfig {
116    /// Talk to `service` as `app`, with the [`DEFAULT_DEADLINE`], the suite resolved from the
117    /// environment, and no hardening. Release builds should add
118    /// [`ignore_env_overrides(true)`](Self::ignore_env_overrides).
119    pub fn new(service: impl Into<String>, app: impl Into<String>) -> Self {
120        Self {
121            service: service.into(),
122            app: app.into(),
123            suite: None,
124            deadline: Some(DEFAULT_DEADLINE),
125            ignore_env_overrides: false,
126            secret: None,
127        }
128    }
129
130    /// Send `secret` on the `hello` and on every request, for a host that requires one (see
131    /// [`ServiceConfig::private_secret`](crate::ServiceConfig::private_secret)). Pair it with
132    /// [`Client::connect_expecting`]: a secret goes to whatever answered at the endpoint.
133    pub fn secret(mut self, secret: PrivateSecret) -> Self {
134        self.secret = Some(secret);
135        self
136    }
137
138    /// Use this suite instead of resolving one.
139    pub fn suite(mut self, suite: Suite) -> Self {
140        self.suite = Some(suite);
141        self
142    }
143
144    /// Per-request deadline, as for [`Client::connect_with`] (`None` = unbounded).
145    pub fn deadline(mut self, deadline: Option<Duration>) -> Self {
146        self.deadline = deadline;
147        self
148    }
149
150    /// Refuse environment redirection: the suite root comes only from the per-user default
151    /// ([`Suite::per_user_default`]), and an explicit [`suite`](Self::suite) that was derived from
152    /// the environment is an error. Release builds of an app and of its command-line engine set
153    /// this; QA builds and isolated test runs do not (they need `RIGHTKIT_SUITE_ROOT`).
154    pub fn ignore_env_overrides(mut self, ignore: bool) -> Self {
155        self.ignore_env_overrides = ignore;
156        self
157    }
158
159    pub fn service(&self) -> &str {
160        &self.service
161    }
162
163    pub fn app(&self) -> &str {
164        &self.app
165    }
166
167    /// Whether [`ignore_env_overrides`](Self::ignore_env_overrides) is on.
168    pub fn ignores_env_overrides(&self) -> bool {
169        self.ignore_env_overrides
170    }
171
172    /// The suite this configuration connects through, per the rules on [`ClientConfig`].
173    #[allow(deprecated)] // kept on the legacy switch until hosts adopt `mode::roots`
174    pub fn resolve_suite(&self) -> Result<Suite, SuiteError> {
175        match (&self.suite, self.ignore_env_overrides) {
176            (Some(suite), true) if suite.env_derived() => Err(SuiteError::Insecure(format!(
177                "the suite {} was derived from the environment, which this client ignores; \
178                 pass Suite::at(..) or leave the suite unset",
179                suite.root().display()
180            ))),
181            (Some(suite), _) => Ok(suite.clone()),
182            (None, ignore) => Suite::resolve_ignoring_env_overrides(ignore),
183        }
184    }
185}
186
187fn suite_error(e: SuiteError) -> ServiceError {
188    ServiceError::new(ERROR_SUITE_UNRESOLVED, e.to_string())
189}
190
191/// A connection to one host of a service. One request in flight at a time;
192/// open a second `Client` for parallel calls. Dropping it closes the
193/// connection, which the host treats as a disconnect (jobs started with
194/// `cancel_on_disconnect` are cancelled).
195pub struct Client {
196    io: Mutex<Io>,
197    next_id: AtomicU64,
198    dead: AtomicBool,
199    hello: Value,
200    record: ServiceRecord,
201    shutdown: Arc<dyn Fn() + Send + Sync>,
202    /// Per-request deadline in ms; 0 = none.
203    deadline_ms: AtomicU64,
204    /// The host the OS identified, for a client opened with [`Client::connect_expecting`].
205    host: Option<HostProcess>,
206    /// Sent on every request frame when the host requires one.
207    secret: Option<PrivateSecret>,
208}
209
210/// Add the per-run secret to an outgoing frame.
211fn with_secret(mut frame: Value, secret: Option<&PrivateSecret>) -> Value {
212    if let Some(secret) = secret {
213        frame["secret"] = Value::String(secret.expose().to_string());
214    }
215    frame
216}
217
218fn closed() -> ServiceError {
219    ServiceError::retryable(
220        ERROR_CONNECTION_CLOSED,
221        "connection closed before a response arrived",
222    )
223}
224
225impl Client {
226    /// Discover the live host of `service` on `suite` and say hello as `app`,
227    /// bounded by [`DEFAULT_DEADLINE`].
228    ///
229    /// **The host is not authenticated.** Discovery is a file (`service.json`) and a socket path
230    /// that any program running as the same user can write or bind, and `suite` itself may have
231    /// come from an environment variable. The transport proves only that whatever answered runs
232    /// as the current user, so the hello and every request, including any secret in one, go to
233    /// that program. A client that sends secrets, or that acts on what the host answers, must use
234    /// [`Client::connect_expecting`] instead.
235    pub fn connect(suite: &Suite, service: &str, app: &str) -> Result<Client, ServiceError> {
236        Self::connect_with(suite, service, app, Some(DEFAULT_DEADLINE))
237    }
238
239    /// Like [`Client::connect`] with an explicit deadline for connect + hello,
240    /// which also becomes the client's per-request deadline (`None` = unbounded).
241    /// The host is not authenticated: see [`Client::connect`].
242    pub fn connect_with(
243        suite: &Suite,
244        service: &str,
245        app: &str,
246        deadline: Option<Duration>,
247    ) -> Result<Client, ServiceError> {
248        Self::connect_bounded(suite, service, app, deadline, None, None)
249            .map_err(ConnectError::into_service_error)
250    }
251
252    /// Connect through a [`ClientConfig`]: the suite and deadline come from the configuration,
253    /// and [`ClientConfig::ignore_env_overrides`] is honoured. The host is still not
254    /// authenticated (see [`Client::connect`]); this hardens only where the client looks.
255    pub fn connect_config(config: &ClientConfig) -> Result<Client, ServiceError> {
256        let suite = config.resolve_suite().map_err(suite_error)?;
257        Self::connect_bounded(
258            &suite,
259            &config.service,
260            &config.app,
261            config.deadline,
262            None,
263            config.secret.clone(),
264        )
265        .map_err(ConnectError::into_service_error)
266    }
267
268    /// Discover the host of the configured service, verify it is the program `expect` names, and
269    /// only then say hello.
270    ///
271    /// The OS is asked who is on the listening end of the connected socket: its uid must equal
272    /// this process's, and its executable (read from the kernel for that process, canonicalised)
273    /// must be one of `expect`'s. The process id is proven stable across the read (start time,
274    /// and on macOS the audit token, before and after, and the pid re-read from the socket). A
275    /// caller-supplied [`HostExpectation::with_verifier`] check then runs. Platforms: macOS
276    /// (`getpeereid`, `LOCAL_PEERPID`, `proc_pidpath`), Linux (`SO_PEERCRED`, `/proc/<pid>/exe`),
277    /// Windows (the pipe's server process: its token user, a held process handle,
278    /// `QueryFullProcessImageNameW`); elsewhere the refusal is
279    /// [`HostIdentityReason::Unverified`](crate::HostIdentityReason::Unverified) and nothing is
280    /// sent. See the README, "Verifying the host".
281    ///
282    /// **Order.** The check runs after the kernel has completed the socket `connect` and before
283    /// anything is written. Nothing application-level precedes it: no hello, no request, no
284    /// length prefix. The only thing the other side can observe of a refused attempt is a
285    /// connection that closes without a byte. The deadline in `config` bounds the whole attempt.
286    ///
287    /// A refusal is [`ConnectError::HostIdentity`], distinct from [`ConnectError::Service`]
288    /// (no record, unreachable, timeout, hello refused). It is not retryable. The check is made
289    /// once, at connect, for the life of the connection.
290    pub fn connect_expecting(
291        config: &ClientConfig,
292        expect: &HostExpectation,
293    ) -> Result<Client, ConnectError> {
294        let suite = config
295            .resolve_suite()
296            .map_err(|e| ConnectError::Service(suite_error(e)))?;
297        Self::connect_bounded(
298            &suite,
299            &config.service,
300            &config.app,
301            config.deadline,
302            Some(expect.clone()),
303            config.secret.clone(),
304        )
305    }
306
307    /// Run [`Client::handshake`] under `deadline`. A connect() that never completes cannot be
308    /// cancelled, so it runs on a helper thread; a client that arrives after the deadline is
309    /// dropped there.
310    fn connect_bounded(
311        suite: &Suite,
312        service: &str,
313        app: &str,
314        deadline: Option<Duration>,
315        expect: Option<HostExpectation>,
316        secret: Option<PrivateSecret>,
317    ) -> Result<Client, ConnectError> {
318        let Some(d) = deadline else {
319            return Self::handshake(suite, service, app, None, expect.as_ref(), secret);
320        };
321        let (tx, rx) = mpsc::channel();
322        let (s, sv, ap) = (suite.clone(), service.to_string(), app.to_string());
323        std::thread::spawn(move || {
324            let _ = tx.send(Self::handshake(
325                &s,
326                &sv,
327                &ap,
328                Some(d),
329                expect.as_ref(),
330                secret,
331            ));
332        });
333        match rx.recv_timeout(d + Duration::from_millis(250)) {
334            Ok(r) => r,
335            Err(_) => Err(ConnectError::Service(timed_out("connect", deadline))),
336        }
337    }
338
339    fn handshake(
340        suite: &Suite,
341        service: &str,
342        app: &str,
343        deadline: Option<Duration>,
344        expect: Option<&HostExpectation>,
345        secret: Option<PrivateSecret>,
346    ) -> Result<Client, ConnectError> {
347        let started = Instant::now();
348        // Absent and unreadable are both retryable `service_unavailable`, but they are not the
349        // same statement: an unreadable record says nothing about whether the host is gone.
350        let record = match try_read_record(suite, service) {
351            Ok(Some(record)) => record,
352            Ok(None) => {
353                return Err(ServiceError::retryable(
354                    ERROR_UNAVAILABLE,
355                    format!("no service.json for {service}"),
356                )
357                .into())
358            }
359            Err(e) => {
360                return Err(ServiceError::retryable(
361                    ERROR_UNAVAILABLE,
362                    format!("could not read service.json for {service} (try again): {e}"),
363                )
364                .into())
365            }
366        };
367        if record.protocol_major != PROTOCOL_MAJOR {
368            return Err(ServiceError::new(
369                "protocol_mismatch",
370                format!("host speaks protocol {}", record.protocol_major),
371            )
372            .into());
373        }
374        let unreachable_host = |e: std::io::Error| {
375            ServiceError::retryable(
376                ERROR_UNAVAILABLE,
377                format!("{service} host unreachable: {e}"),
378            )
379        };
380        // With an expectation, the host is identified here, before the first byte is written.
381        let (conn, host) = match expect {
382            None => (connect(&record.endpoint).map_err(unreachable_host)?, None),
383            Some(expect) => match connect_expecting(&record.endpoint, expect) {
384                Ok((conn, host)) => (conn, Some(host)),
385                Err(HostConnectError::Io(e)) => return Err(unreachable_host(e).into()),
386                Err(HostConnectError::Identity(e)) => return Err(ConnectError::HostIdentity(e)),
387            },
388        };
389        let Conn {
390            reader,
391            writer,
392            shutdown,
393            ..
394        } = conn;
395        let mut io = Io {
396            reader: BufReader::new(reader),
397            writer,
398            buf: Vec::new(),
399        };
400        let wd = Watchdog::arm(
401            deadline.map(|d| d.saturating_sub(started.elapsed())),
402            shutdown.clone(),
403        );
404        let hello_res = (|| {
405            write_json_line(
406                &mut io.writer,
407                &with_secret(
408                    json!({"type": "hello", "app": app, "protocol_major": PROTOCOL_MAJOR}),
409                    secret.as_ref(),
410                ),
411                LIMIT,
412            )
413            .map_err(|e| ServiceError::new("io", e.to_string()))?;
414            io.writer.flush()?;
415            read_json_line::<_, Value>(&mut io.reader, &mut io.buf, LIMIT)
416                .map_err(|e| ServiceError::new("io", e.to_string()))?
417                .ok_or_else(closed)
418        })();
419        let hello = match hello_res {
420            Ok(h) => h,
421            Err(e) => {
422                let t = wd.fired();
423                (shutdown)();
424                let e = if t { timed_out("hello", deadline) } else { e };
425                return Err(e.into());
426            }
427        };
428        drop(wd);
429        if hello["ok"] != true {
430            return Err(ServiceError::from_value(&hello["error"]).into());
431        }
432        Ok(Client {
433            io: Mutex::new(io),
434            next_id: AtomicU64::new(1),
435            dead: AtomicBool::new(false),
436            hello,
437            record,
438            shutdown,
439            deadline_ms: AtomicU64::new(deadline.map(|d| d.as_millis() as u64).unwrap_or(0)),
440            host,
441            secret,
442        })
443    }
444
445    /// The host process the OS identified and this client verified, for a client opened with
446    /// [`Client::connect_expecting`]; `None` for one opened with [`Client::connect`] or
447    /// [`Client::connect_with`], whose host was never checked.
448    pub fn verified_host(&self) -> Option<&HostProcess> {
449        self.host.as_ref()
450    }
451
452    /// Set the per-request deadline for later calls (`None` = unbounded).
453    pub fn set_deadline(&self, deadline: Option<Duration>) {
454        self.deadline_ms.store(
455            deadline.map(|d| (d.as_millis() as u64).max(1)).unwrap_or(0),
456            Ordering::SeqCst,
457        );
458    }
459
460    pub fn record(&self) -> &ServiceRecord {
461        &self.record
462    }
463    /// The host's hello: service, role, rank, version, and the methods this app may call.
464    pub fn hello(&self) -> &Value {
465        &self.hello
466    }
467
468    /// Close the connection from any thread. A request blocked in
469    /// [`Client::call`] returns `connection_closed`; the host sees the disconnect.
470    pub fn abort(&self) {
471        self.dead.store(true, Ordering::SeqCst);
472        (self.shutdown)();
473    }
474
475    pub fn call(&self, method: &str, params: Value) -> Result<Value, ServiceError> {
476        self.call_events(method, params, &mut |_| {})
477    }
478
479    /// Like [`Client::call`], streaming the request's `event` frames to `on_event`.
480    ///
481    /// The per-request deadline starts when this method is entered and covers waiting for the
482    /// connection (another call in flight), the write, and the reply: one monotonic deadline.
483    pub fn call_events(
484        &self,
485        method: &str,
486        params: Value,
487        on_event: &mut dyn FnMut(&Value),
488    ) -> Result<Value, ServiceError> {
489        let deadline = match self.deadline_ms.load(Ordering::SeqCst) {
490            0 => None,
491            ms => Some(Instant::now() + Duration::from_millis(ms)),
492        };
493        self.call_events_until(method, params, deadline, on_event)
494    }
495
496    /// Like [`Client::call_events`], bounded by an absolute monotonic `deadline` (`None` =
497    /// unbounded) that the caller fixed earlier. Waiting for a request already in flight on this
498    /// connection counts against it: when it passes first, this returns the typed timeout without
499    /// aborting the connection, because the request that holds it is not this one's.
500    pub fn call_events_until(
501        &self,
502        method: &str,
503        params: Value,
504        deadline: Option<Instant>,
505        on_event: &mut dyn FnMut(&Value),
506    ) -> Result<Value, ServiceError> {
507        let mut frame = self.call_frame_until(method, params, false, deadline, on_event)?;
508        Ok(frame["result"].take())
509    }
510
511    /// One request and its whole response frame. `chunked_ok` tells the host it may answer a
512    /// result larger than a frame in pieces (see the `chunk` module).
513    fn call_frame_until(
514        &self,
515        method: &str,
516        params: Value,
517        chunked_ok: bool,
518        deadline: Option<Instant>,
519        on_event: &mut dyn FnMut(&Value),
520    ) -> Result<Value, ServiceError> {
521        let started = Instant::now();
522        let total = deadline.map(|d| d.saturating_duration_since(started));
523        if self.dead.load(Ordering::SeqCst) {
524            return Err(closed());
525        }
526        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
527        let Some(mut io) = lock_until(&self.io, deadline) else {
528            return Err(timed_out(
529                &format!("request `{method}` waiting for the connection"),
530                total,
531            ));
532        };
533        if self.dead.load(Ordering::SeqCst) {
534            return Err(closed());
535        }
536        let wd = Watchdog::arm(
537            deadline.map(|d| d.saturating_duration_since(Instant::now())),
538            self.shutdown.clone(),
539        );
540        let res = (|| {
541            let mut frame =
542                json!({"type": "request", "id": id, "method": method, "params": params});
543            if chunked_ok {
544                frame["chunked_ok"] = Value::Bool(true);
545            }
546            write_json_line(
547                &mut io.writer,
548                &with_secret(frame, self.secret.as_ref()),
549                LIMIT,
550            )
551            .map_err(|e| match e {
552                FrameError::TooLarge { .. } => ServiceError::new(
553                    ERROR_REQUEST_TOO_LARGE,
554                    "request exceeds the frame limit (Client::call_large sends it in pieces)",
555                ),
556                _ => closed(),
557            })?;
558            io.writer.flush().map_err(|_| closed())?;
559            loop {
560                let Io { reader, buf, .. } = &mut *io;
561                match read_json_line::<_, Value>(reader, buf, LIMIT) {
562                    Ok(Some(v)) if v["id"].as_u64() == Some(id) => {
563                        if v["type"] == "event" {
564                            on_event(&v["event"]);
565                        } else if v["type"] == "response" {
566                            return if v["ok"] == true {
567                                Ok(v)
568                            } else {
569                                Err(ServiceError::from_value(&v["error"]))
570                            };
571                        }
572                    }
573                    Ok(Some(_)) => {} // frame for another request or a connection-level error
574                    Ok(None) | Err(FrameError::Io(_)) => return Err(closed()),
575                    // The frame was drained through its newline, so the stream is still on a
576                    // frame boundary and the connection stays usable. Skipping it silently left
577                    // the caller waiting for the full deadline for a reply that already came.
578                    Err(FrameError::TooLarge { .. }) => {
579                        return Err(ServiceError::new(
580                            ERROR_RESPONSE_TOO_LARGE,
581                            "the host's reply exceeds the client's frame limit",
582                        ));
583                    }
584                    Err(_) => {}
585                }
586            }
587        })();
588        if wd.fired() && res.is_err() {
589            self.dead.store(true, Ordering::SeqCst);
590            return Err(timed_out(&format!("request `{method}`"), total));
591        }
592        if matches!(&res, Err(e) if e.code == ERROR_CONNECTION_CLOSED) {
593            self.dead.store(true, Ordering::SeqCst);
594        }
595        res
596    }
597}
598
599impl Client {
600    /// One frame of a chunked transfer: its own per-request deadline, the whole response frame.
601    fn call_step(
602        &self,
603        method: &str,
604        params: Value,
605        chunked_ok: bool,
606        on_event: &mut dyn FnMut(&Value),
607    ) -> Result<Value, ServiceError> {
608        let deadline = match self.deadline_ms.load(Ordering::SeqCst) {
609            0 => None,
610            ms => Some(Instant::now() + Duration::from_millis(ms)),
611        };
612        self.call_frame_until(method, params, chunked_ok, deadline, on_event)
613    }
614
615    /// Like [`Client::call_events`] for a request or a result that may be larger than one frame
616    /// (issue #294). The frame limit stays the cap on every frame; a message above it is cut into
617    /// pieces and assembled by the other side under the host's separate message bound
618    /// ([`HostOptions::max_message`](crate::HostOptions::max_message)), as the `chunk` module
619    /// describes.
620    ///
621    /// * Params that fit in half the host's frame go as one ordinary request, and a result that
622    ///   does not fit one frame comes back in pieces (this call opts in to that).
623    /// * Larger params are uploaded in pieces through the reserved `rk.upload` method; the host
624    ///   runs `method` once the last piece arrives, and its response answers this call. A host
625    ///   that predates chunking (no `max_message` in its `hello`) or has it off refuses with
626    ///   `request_too_large`, and so does a message beyond the host's bound, before any piece is
627    ///   sent.
628    ///
629    /// Each frame is its own request with its own deadline (the per-request deadline set on the
630    /// client), so a long transfer is not cut by one deadline for the whole. A failure part-way
631    /// leaves nothing in the host after the next call or the end of the connection; resubmitting
632    /// the whole call is safe for the transfer itself, but whether `method` may run twice is the
633    /// caller's call, as for [`Client::call`]. Events of the call (`on_event`) arrive with the
634    /// final upload piece's request.
635    pub fn call_large(
636        &self,
637        method: &str,
638        params: Value,
639        on_event: &mut dyn FnMut(&Value),
640    ) -> Result<Value, ServiceError> {
641        use crate::chunk::{boundaries, CHUNK_TEXT_BYTES, METHOD_FETCH, METHOD_UPLOAD};
642        let advertised = |key: &str| {
643            self.hello[key]
644                .as_u64()
645                .and_then(|n| usize::try_from(n).ok())
646        };
647        let max_message = advertised("max_message").unwrap_or(0);
648        let frame_limit = advertised("frame_limit").unwrap_or(LIMIT).min(LIMIT);
649        let chunk = advertised("chunk_bytes")
650            .unwrap_or(CHUNK_TEXT_BYTES)
651            .clamp(16, CHUNK_TEXT_BYTES);
652        let text = serde_json::to_string(&params)
653            .map_err(|e| ServiceError::new("bad_request", e.to_string()))?;
654        let single_max = if max_message == 0 {
655            // Leave room for the request envelope, but never more than half the frame: a host
656            // with a small frame limit still takes small calls (the margin was a flat 64 KiB,
657            // which made every call over a frame under 64 KiB `request_too_large`).
658            frame_limit - (64 * 1024).min(frame_limit / 2)
659        } else {
660            frame_limit / 2
661        };
662        let mut frame = if text.len() <= single_max {
663            drop(text);
664            self.call_step(method, params, max_message > 0, &mut *on_event)?
665        } else {
666            if max_message == 0 || text.len() > max_message {
667                return Err(ServiceError::new(
668                    ERROR_REQUEST_TOO_LARGE,
669                    if max_message == 0 {
670                        "the request exceeds the frame limit and the host does not chunk"
671                            .to_string()
672                    } else {
673                        format!(
674                            "the request is {} bytes, over the host's {max_message}-byte message limit",
675                            text.len()
676                        )
677                    },
678                ));
679            }
680            drop(params);
681            let ends = boundaries(&text, chunk);
682            let upload_id = format!("u{}", self.next_id.fetch_add(1, Ordering::SeqCst));
683            let mut start = 0;
684            let mut answer = Value::Null;
685            for (seq, end) in ends.iter().enumerate() {
686                let last = seq + 1 == ends.len();
687                let mut piece = json!({
688                    "id": upload_id, "seq": seq, "last": last, "text": &text[start..*end],
689                });
690                if seq == 0 {
691                    piece["method"] = Value::String(method.to_string());
692                }
693                start = *end;
694                let frame = self.call_step(METHOD_UPLOAD, piece, last, &mut *on_event)?;
695                if last {
696                    answer = frame;
697                }
698            }
699            answer
700        };
701        let held = frame.get_mut("chunked").map(Value::take);
702        let Some(held) = held else {
703            return Ok(frame["result"].take());
704        };
705        // The result came back in pieces.
706        let bad = |why: &str| ServiceError::new(ERROR_BAD_CHUNK, why.to_string());
707        let id = held["id"]
708            .as_str()
709            .ok_or_else(|| bad("the chunked result has no id"))?
710            .to_string();
711        let bytes = held["bytes"]
712            .as_u64()
713            .and_then(|n| usize::try_from(n).ok())
714            .ok_or_else(|| bad("the chunked result has no size"))?;
715        let count = held["count"]
716            .as_u64()
717            .ok_or_else(|| bad("the chunked result has no count"))?;
718        if bytes > max_message || count > bytes.max(1) as u64 {
719            return Err(bad(
720                "the chunked result is larger than the host's own message limit",
721            ));
722        }
723        let mut joined = String::with_capacity(bytes);
724        for seq in 0..count {
725            let request = json!({"id": id, "seq": seq});
726            let piece = self.call_step(METHOD_FETCH, request, false, &mut *on_event)?;
727            let part = piece["result"]["text"]
728                .as_str()
729                .ok_or_else(|| bad("a result piece has no text"))?;
730            if joined.len() + part.len() > bytes {
731                return Err(bad("the result pieces add up to more than announced"));
732            }
733            joined.push_str(part);
734        }
735        if joined.len() != bytes {
736            return Err(bad("the result pieces add up to less than announced"));
737        }
738        serde_json::from_str(&joined)
739            .map_err(|e| bad(&format!("the assembled result is not JSON: {e}")))
740    }
741}
742
743/// Reconnect-and-resubmit policy for retryable errors (handoff, host restart).
744#[derive(Debug, Clone, Copy)]
745pub struct RetryPolicy {
746    pub max_attempts: u32,
747    pub backoff: Duration,
748    /// Give up after this long regardless of attempts. Not a request timeout:
749    /// a single call may run as long as the host keeps it alive.
750    pub give_up_after: Duration,
751    /// Per-attempt connect + request deadline (`None` = unbounded).
752    pub request_deadline: Option<Duration>,
753    /// The request is safe to run twice. When false (the default) a request is
754    /// resubmitted only if the host provably did not run it (service unavailable
755    /// at connect, or an explicit host handoff); a connection that closed after
756    /// the request was written is returned as-is, and a timeout is returned as-is.
757    /// When true, a connection that closed after the write and a timeout are resubmitted too.
758    pub idempotent: bool,
759}
760
761impl Default for RetryPolicy {
762    fn default() -> Self {
763        Self {
764            max_attempts: 8,
765            backoff: Duration::from_millis(150),
766            give_up_after: Duration::from_secs(120),
767            request_deadline: Some(DEFAULT_DEADLINE),
768            idempotent: false,
769        }
770    }
771}
772
773/// One attempt: `call_large`, and when `stop` is given a watcher thread that aborts the connection
774/// once `stop` returns `true` while the call is in flight.
775fn run_attempt(
776    c: &Client,
777    method: &str,
778    params: &Value,
779    stop: Option<&(dyn Fn() -> bool + Sync)>,
780    on_event: &mut dyn FnMut(&Value),
781) -> Result<Value, ServiceError> {
782    let Some(stop) = stop else {
783        return c.call_large(method, params.clone(), on_event);
784    };
785    let done = AtomicBool::new(false);
786    std::thread::scope(|scope| {
787        scope.spawn(|| {
788            while !done.load(Ordering::SeqCst) {
789                if stop() {
790                    c.abort();
791                    break;
792                }
793                std::thread::sleep(Duration::from_millis(25));
794            }
795        });
796        let result = c.call_large(method, params.clone(), on_event);
797        done.store(true, Ordering::SeqCst);
798        result
799    })
800}
801
802/// Sleep for `d`, in slices of at most 25 ms when a stop hook is given, returning early once it
803/// reports `true`.
804fn sleep_unless_stopped(d: Duration, stop: Option<&(dyn Fn() -> bool + Sync)>) {
805    let Some(stop) = stop else {
806        std::thread::sleep(d);
807        return;
808    };
809    let end = Instant::now() + d;
810    loop {
811        if stop() {
812            return;
813        }
814        let now = Instant::now();
815        if now >= end {
816            return;
817        }
818        std::thread::sleep((end - now).min(Duration::from_millis(25)));
819    }
820}
821
822impl Client {
823    /// Connect, call, and on a retryable error (host handoff, closed connection,
824    /// service unavailable) re-read discovery and resubmit the whole request.
825    /// The host is not authenticated: see [`Client::connect`].
826    pub fn call_retrying(
827        suite: &Suite,
828        service: &str,
829        app: &str,
830        method: &str,
831        params: Value,
832        policy: RetryPolicy,
833        on_event: &mut dyn FnMut(&Value),
834    ) -> Result<Value, ServiceError> {
835        Self::retry_loop(
836            &|deadline| Self::connect_bounded(suite, service, app, deadline, None, None),
837            method,
838            params,
839            policy,
840            None,
841            on_event,
842        )
843    }
844
845    /// [`Client::call_retrying`] that verifies the host on every attempt, as
846    /// [`Client::connect_expecting`] does. A host identity refusal is not retryable and is
847    /// returned at once as a [`ServiceError`] with code `host_identity`
848    /// ([`ERROR_HOST_IDENTITY`]); it is never replayed against another attempt. The per-attempt
849    /// deadline is `policy.request_deadline`, not the configuration's.
850    pub fn call_retrying_expecting(
851        config: &ClientConfig,
852        expect: &HostExpectation,
853        method: &str,
854        params: Value,
855        policy: RetryPolicy,
856        on_event: &mut dyn FnMut(&Value),
857    ) -> Result<Value, ServiceError> {
858        let suite = config.resolve_suite().map_err(suite_error)?;
859        Self::retry_loop(
860            &|deadline| {
861                Self::connect_bounded(
862                    &suite,
863                    &config.service,
864                    &config.app,
865                    deadline,
866                    Some(expect.clone()),
867                    config.secret.clone(),
868                )
869            },
870            method,
871            params,
872            policy,
873            None,
874            on_event,
875        )
876    }
877
878    /// Retry over a connection source the caller owns, instead of a fresh connection per call:
879    /// `connect` returns a client for each attempt (a held client, a pool, or a closure that
880    /// connects with its own verification). The request goes through [`Client::call_large`], so a
881    /// request or result larger than one frame works, and the same retry rules as
882    /// [`Client::call_retrying`] apply (see [`RetryPolicy::idempotent`]). `connect` gets the
883    /// per-attempt deadline from `policy.request_deadline`.
884    pub fn call_retrying_over<F>(
885        connect: F,
886        method: &str,
887        params: Value,
888        policy: RetryPolicy,
889        on_event: &mut dyn FnMut(&Value),
890    ) -> Result<Value, ServiceError>
891    where
892        F: Fn(Option<Duration>) -> Result<Client, ConnectError>,
893    {
894        Self::retry_loop(&connect, method, params, policy, None, on_event)
895    }
896
897    /// [`Client::call_retrying_over`] for a call that runs until it is stopped, such as a long event
898    /// subscription. `stop` is checked before each attempt, during the backoff, and while an
899    /// attempt is in flight (a watcher thread aborts the connection once it returns `true`, which
900    /// ends that attempt with `connection_closed` and no further attempt is made). For a call
901    /// with no end, also set `policy.give_up_after` to `Duration::MAX`: the give-up limit counts
902    /// from the first attempt and would otherwise end it.
903    pub fn call_retrying_until<F>(
904        connect: F,
905        method: &str,
906        params: Value,
907        policy: RetryPolicy,
908        stop: &(dyn Fn() -> bool + Sync),
909        on_event: &mut dyn FnMut(&Value),
910    ) -> Result<Value, ServiceError>
911    where
912        F: Fn(Option<Duration>) -> Result<Client, ConnectError>,
913    {
914        Self::retry_loop(&connect, method, params, policy, Some(stop), on_event)
915    }
916
917    fn retry_loop(
918        connect: &dyn Fn(Option<Duration>) -> Result<Client, ConnectError>,
919        method: &str,
920        params: Value,
921        policy: RetryPolicy,
922        stop: Option<&(dyn Fn() -> bool + Sync)>,
923        on_event: &mut dyn FnMut(&Value),
924    ) -> Result<Value, ServiceError> {
925        let started = Instant::now();
926        let stopped = || stop.is_some_and(|f| f());
927        let mut last = ServiceError::retryable(ERROR_UNAVAILABLE, "no attempt made");
928        for attempt in 0..policy.max_attempts.max(1) {
929            if attempt > 0 {
930                if stopped() || started.elapsed() > policy.give_up_after {
931                    break;
932                }
933                sleep_unless_stopped(policy.backoff * attempt.min(8), stop);
934                if stopped() {
935                    break;
936                }
937            }
938            let c = match connect(policy.request_deadline) {
939                Ok(c) => c,
940                Err(ConnectError::Service(e)) if e.retryable => {
941                    last = e; // never reached the host's request path
942                    continue;
943                }
944                Err(e) => return Err(e.into_service_error()),
945            };
946            match run_attempt(&c, method, &params, stop, on_event) {
947                Ok(v) => return Ok(v),
948                // Handoff means the host declined the request. A closed connection or a timeout
949                // may have run it: replayed only when the caller says the request is idempotent.
950                Err(e)
951                    if (e.retryable && (e.code == ERROR_HOST_HANDOFF || policy.idempotent))
952                        || (e.code == ERROR_TIMEOUT && policy.idempotent) =>
953                {
954                    last = e
955                }
956                Err(e) => return Err(e),
957            }
958        }
959        Err(last)
960    }
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    #[test]
968    fn defaults_are_the_unhardened_resolve_with_the_default_deadline() {
969        let cfg = ClientConfig::new("svc", "app");
970        assert_eq!((cfg.service(), cfg.app()), ("svc", "app"));
971        assert!(!cfg.ignores_env_overrides());
972        assert_eq!(cfg.deadline, Some(DEFAULT_DEADLINE));
973    }
974
975    #[test]
976    fn an_explicit_suite_is_used_as_is_unless_it_came_from_the_environment_and_the_switch_is_on() {
977        let dir = tempfile::tempdir().unwrap();
978        let explicit = Suite::at(dir.path()).unwrap();
979        for ignore in [false, true] {
980            let cfg = ClientConfig::new("svc", "app")
981                .suite(explicit.clone())
982                .ignore_env_overrides(ignore);
983            assert_eq!(cfg.resolve_suite().unwrap().root(), explicit.root());
984        }
985        // `Suite::resolve` fails only where the environment names no root at all.
986        #[cfg(feature = "test-mode")]
987        if let Ok(from_env) = Suite::resolve() {
988            let lenient = ClientConfig::new("svc", "app").suite(from_env);
989            assert!(lenient.resolve_suite().is_ok());
990            let strict = lenient.ignore_env_overrides(true);
991            assert!(matches!(
992                strict.resolve_suite(),
993                Err(SuiteError::Insecure(_))
994            ));
995        }
996    }
997
998    #[test]
999    fn a_missing_record_is_a_retryable_service_error_not_an_identity_error() {
1000        let dir = tempfile::tempdir().unwrap();
1001        let cfg = ClientConfig::new("nobody-home", "app").suite(Suite::at(dir.path()).unwrap());
1002        let err = Client::connect_expecting(&cfg, &HostExpectation::executable("/x"))
1003            .err()
1004            .expect("there is no host");
1005        match err {
1006            ConnectError::Service(e) => {
1007                assert_eq!(e.code, ERROR_UNAVAILABLE);
1008                assert!(e.retryable);
1009            }
1010            other => panic!("expected a service error, got {other:?}"),
1011        }
1012    }
1013}