Skip to main content

iroh_services/
client.rs

1use std::{
2    collections::BTreeMap,
3    fmt::Display,
4    str::FromStr,
5    sync::{Arc, RwLock},
6};
7
8use anyhow::Result;
9use iroh::{
10    Endpoint, EndpointAddr, EndpointId,
11    endpoint::{ConnectError, Connection},
12};
13use iroh_metrics::{MetricsGroup, Registry, encoding::Encoder};
14use iroh_services_proto::{
15    ATTRIBUTE_VALUE_MAX_LENGTH, ATTRIBUTES_MAX_COUNT, Auth, GrantCap, IrohServicesClient,
16    IrohServicesProtocol, NameEndpoint, Ping, Pong as ProtoPong, PutMetrics, PutNetworkDiagnostics,
17    RemoteError as ProtoRemoteError, ServicesMessage, SetAttributes, SetGroup,
18    caps::Caps as ProtoCaps,
19};
20use irpc::{Channels, RpcMessage, WithChannels, channel::none::NoReceiver};
21use irpc_iroh::IrohRemoteConnection;
22use n0_error::StackResultExt;
23use n0_future::{
24    task::{self, AbortOnDropHandle},
25    time::{self, Duration},
26};
27use rcan::Rcan;
28use serde::{Deserialize, Serialize};
29use tokio::sync::oneshot;
30use tokio_util::sync::CancellationToken;
31use tracing::{debug, trace, warn};
32use uuid::Uuid;
33
34use crate::{
35    ALPN,
36    api_secret::{API_SECRET_ENV_VAR_NAME, ApiSecret},
37    caps::{Caps, DEFAULT_CAP_EXPIRY},
38    net_diagnostics::{DiagnosticsReport, checks::run_diagnostics},
39};
40
41/// Client is the main handle for interacting with iroh-services. It communicates with
42/// iroh-services entirely through an iroh endpoint, and is configured through a builder.
43/// Client requires either an Ssh Key or [`ApiSecret`]
44///
45/// ```no_run
46/// use iroh::{Endpoint, endpoint::presets};
47/// use iroh_services::Client;
48///
49/// async fn build_client() -> anyhow::Result<()> {
50///     let endpoint = Endpoint::bind(presets::N0).await?;
51///
52///     // needs IROH_SERVICES_API_SECRET set to an environment variable
53///     // client will now push endpoint metrics to iroh-services.
54///     let client = Client::builder(&endpoint)
55///         .api_secret_from_str("MY_API_SECRET")?
56///         .build()
57///         .await;
58///
59///     Ok(())
60/// }
61/// ```
62///
63/// [`ApiSecret`]: crate::api_secret::ApiSecret
64#[derive(Debug, Clone)]
65pub struct Client {
66    // owned clone of the endpoint for diagnostics, and for connection restarts on actor close
67    endpoint: Endpoint,
68    message_channel: tokio::sync::mpsc::Sender<ClientActorMessage>,
69    /// Cancelled by [`Client::shutdown`] to stop whatever the actor is doing.
70    shutdown: CancellationToken,
71    _actor_task: Arc<AbortOnDropHandle<()>>,
72}
73
74/// ClientBuilder provides configures and builds a iroh-services client, typically
75/// created with [`Client::builder`]
76pub struct ClientBuilder {
77    cap_expiry: Duration,
78    cap: Option<Rcan<ProtoCaps>>,
79    endpoint: Endpoint,
80    name: Option<String>,
81    group: Option<String>,
82    attributes: Option<BTreeMap<String, String>>,
83    metrics_interval: Option<Duration>,
84    remote: Option<EndpointAddr>,
85    registry: Registry,
86}
87
88/// A response to [`Client::ping`].
89#[derive(Debug, Serialize, Deserialize)]
90pub struct Pong {
91    pub req_id: [u8; 16],
92}
93
94/// An error returned by the remote iroh-services endpoint.
95#[derive(Clone, Serialize, Deserialize, thiserror::Error, Debug)]
96#[non_exhaustive]
97pub enum RemoteError {
98    #[error("Missing capability: {}", _0.0.to_strings().join(", "))]
99    MissingCapability(#[serde(with = "missing_capability_serde")] Caps),
100    #[error("Unauthorized: {0}")]
101    AuthError(String),
102    #[error("Internal server error")]
103    InternalServerError,
104    #[error("Invalid input: {0}")]
105    InvalidInput(String),
106    #[error("Rate limit exceeded")]
107    RateLimited,
108}
109
110mod missing_capability_serde {
111    use serde::{Deserialize, Serialize};
112
113    use super::{Caps, ProtoCaps};
114
115    pub(super) fn serialize<S>(caps: &Caps, serializer: S) -> Result<S::Ok, S::Error>
116    where
117        S: serde::Serializer,
118    {
119        caps.0.serialize(serializer)
120    }
121
122    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Caps, D::Error>
123    where
124        D: serde::Deserializer<'de>,
125    {
126        ProtoCaps::deserialize(deserializer).map(Caps)
127    }
128}
129
130impl RemoteError {
131    fn from_proto(error: ProtoRemoteError) -> Self {
132        match error {
133            ProtoRemoteError::MissingCapability(caps) => Self::MissingCapability(Caps(caps)),
134            ProtoRemoteError::AuthError(error) => Self::AuthError(error),
135            ProtoRemoteError::InternalServerError => Self::InternalServerError,
136            ProtoRemoteError::InvalidInput(error) => Self::InvalidInput(error),
137            ProtoRemoteError::RateLimited => Self::RateLimited,
138            _ => Self::InternalServerError,
139        }
140    }
141}
142
143impl ClientBuilder {
144    pub fn new(endpoint: &Endpoint) -> Self {
145        let mut registry = Registry::default();
146        registry.register_all(endpoint.metrics());
147
148        Self {
149            cap: None,
150            cap_expiry: DEFAULT_CAP_EXPIRY,
151            endpoint: endpoint.clone(),
152            name: None,
153            group: None,
154            attributes: None,
155            metrics_interval: Some(Duration::from_secs(60)),
156            remote: None,
157            registry,
158        }
159    }
160
161    /// Register a metrics group to forward to iroh-services
162    ///
163    /// The default registered metrics uses only the endpoint
164    pub fn register_metrics_group(mut self, metrics_group: Arc<dyn MetricsGroup>) -> Self {
165        self.registry.register(metrics_group);
166        self
167    }
168
169    /// Set the metrics collection interval
170    ///
171    /// Defaults to enabled, every 60 seconds.
172    pub fn metrics_interval(mut self, interval: Duration) -> Self {
173        self.metrics_interval = Some(interval);
174        self
175    }
176
177    /// Disable metrics collection.
178    pub fn disable_metrics_interval(mut self) -> Self {
179        self.metrics_interval = None;
180        self
181    }
182
183    /// Set an optional human-readable name for the endpoint, making its metrics
184    /// easier to identify.
185    ///
186    /// Often a database user id, machine name, or other stable identifier from
187    /// your application. A name must be 2 to 128 bytes of UTF-8; uniqueness is not
188    /// enforced, so different endpoints may share a name.
189    ///
190    /// Validation errors are returned here. The name is sent to the server after
191    /// the client authenticates; a failure to send it at that point is logged at
192    /// warn level rather than returned; use [`Client::set_name`] to set it later
193    /// with explicit error handling.
194    pub fn name(mut self, name: impl Into<String>) -> Result<Self> {
195        let name = name.into();
196        validate_name(&name).map_err(BuildError::InvalidName)?;
197        self.name = Some(name);
198        Ok(self)
199    }
200
201    /// Attach the endpoint to a single named group when the client first
202    /// authenticates.
203    ///
204    /// A group name must be 2 to 128 bytes of UTF-8. Validation errors are returned
205    /// here. The group is sent to the server after the client authenticates; a
206    /// failure to send it at that point is logged at warn level rather than
207    /// returned; use [`Client::set_group`] to set it later with explicit error
208    /// handling.
209    pub fn group(mut self, group: impl Into<String>) -> Result<Self> {
210        let group = group.into();
211        validate_name(&group).map_err(BuildError::InvalidGroup)?;
212        self.group = Some(group);
213        Ok(self)
214    }
215
216    /// Attach arbitrary key-value attributes to the endpoint when the client
217    /// first authenticates. Accepts any iterable of `(key, value)` pairs:
218    ///
219    /// ```no_run
220    /// # use iroh::{Endpoint, endpoint::presets};
221    /// # use iroh_services::Client;
222    /// # async fn example(endpoint: &Endpoint) -> anyhow::Result<()> {
223    /// let _ = Client::builder(endpoint).attributes([("env", "prod"), ("region", "us-west")])?;
224    /// # Ok(()) }
225    /// ```
226    ///
227    /// Each key must be 2 to 128 bytes of UTF-8; values may be empty and are capped
228    /// at 128 bytes; at most 128 entries are allowed. Validation errors are
229    /// returned here. The attributes are sent to the server after the client
230    /// authenticates; a failure to send them at that point is logged at warn
231    /// level rather than returned; use [`Client::set_attributes`] to set them
232    /// later with explicit error handling.
233    pub fn attributes<I, K, V>(mut self, attrs: I) -> Result<Self>
234    where
235        I: IntoIterator<Item = (K, V)>,
236        K: Into<String>,
237        V: Into<String>,
238    {
239        let collected: BTreeMap<String, String> = attrs
240            .into_iter()
241            .map(|(k, v)| (k.into(), v.into()))
242            .collect();
243        validate_attributes(&collected).map_err(BuildError::InvalidAttributes)?;
244        self.attributes = Some(collected);
245        Ok(self)
246    }
247
248    /// Check IROH_SERVICES_API_SECRET environment variable for a valid API secret
249    pub fn api_secret_from_env(self) -> Result<Self> {
250        let ticket = ApiSecret::from_env_var(API_SECRET_ENV_VAR_NAME)?;
251        self.api_secret(ticket)
252    }
253
254    /// set client API secret from an encoded string
255    pub fn api_secret_from_str(self, secret_key: &str) -> Result<Self> {
256        let key = ApiSecret::from_str(secret_key).context("invalid iroh services api secret")?;
257        self.api_secret(key)
258    }
259
260    /// Use a shared secret & remote iroh-services endpoint ID contained within a ticket
261    /// to construct a iroh-services client. The resulting client will have "Client"
262    /// capabilities.
263    ///
264    /// API secrets include remote details within them, and will set both the
265    /// remote and capability token values on the builder
266    pub fn api_secret(mut self, ticket: ApiSecret) -> Result<Self> {
267        let local_id = self.endpoint.id();
268        let token = crate::caps::create_api_token_from_secret_key(
269            ticket.secret,
270            local_id,
271            self.cap_expiry,
272            Caps::client(),
273        )?;
274
275        self.remote = Some(ticket.remote);
276        self.cap.replace(token.into_rcan());
277        Ok(self)
278    }
279
280    /// Loads the private ssh key from the given path, and creates the needed capability.
281    ///
282    /// The file must contain an unencrypted PEM-encoded OpenSSH ed25519 private key.
283    #[cfg(not(wasm_browser))]
284    pub async fn ssh_key_from_file<P: AsRef<std::path::Path>>(self, path: P) -> Result<Self> {
285        let file_content = tokio::fs::read_to_string(path).await?;
286        self.ssh_key(&file_content)
287    }
288
289    /// Creates the capability from the provided PEM-encoded OpenSSH ed25519 private key.
290    #[cfg(not(wasm_browser))]
291    pub fn ssh_key(mut self, pem: &str) -> Result<Self> {
292        let local_id = self.endpoint.id();
293        let token = crate::caps::create_api_token_from_openssh_pem(
294            pem,
295            local_id,
296            self.cap_expiry,
297            Caps(ProtoCaps::all()),
298        )?;
299        self.cap.replace(token.into_rcan());
300
301        Ok(self)
302    }
303
304    /// Sets the remote to dial, must be provided either directly by calling
305    /// this method, or through calling the api_secret builder methods.
306    pub fn remote(mut self, remote: impl Into<EndpointAddr>) -> Self {
307        self.remote = Some(remote.into());
308        self
309    }
310
311    /// Create a new client, connected to the provide service node
312    #[must_use = "dropping the client will silently cancel all client tasks"]
313    pub async fn build(self) -> Result<Client, BuildError> {
314        debug!("starting iroh-services client");
315        let remote = self.remote.ok_or(BuildError::MissingRemote)?;
316        let capabilities = self.cap.ok_or(BuildError::MissingCapability)?;
317
318        let registry = Arc::new(RwLock::new(self.registry));
319        let (tx, rx) = tokio::sync::mpsc::channel(1);
320        let shutdown = CancellationToken::new();
321        let actor_task = AbortOnDropHandle::new(task::spawn(
322            ClientActor {
323                capabilities,
324                endpoint: self.endpoint.clone(),
325                remote,
326                client: None,
327                name: self.name.clone(),
328                group: self.group.clone(),
329                attributes: self.attributes.clone().unwrap_or_default(),
330                session_id: Uuid::new_v4(),
331                encoder: Encoder::new(registry.clone()),
332                registry,
333            }
334            .run(self.metrics_interval, rx, shutdown.clone()),
335        ));
336
337        Ok(Client {
338            endpoint: self.endpoint,
339            message_channel: tx,
340            shutdown,
341            _actor_task: Arc::new(actor_task),
342        })
343    }
344}
345
346#[derive(thiserror::Error, Debug)]
347#[non_exhaustive]
348pub enum BuildError {
349    #[error("Missing remote endpoint to dial")]
350    MissingRemote,
351    #[error("Missing capability")]
352    MissingCapability,
353    #[error("Unauthorized")]
354    Unauthorized,
355    #[error("Remote error: {0}")]
356    Remote(#[from] RemoteError),
357    #[error("Rpc connection error: {0}")]
358    Rpc(irpc::Error),
359    #[error("Connection error: {0}")]
360    Connect(ConnectError),
361    #[error("Invalid endpoint name: {0}")]
362    InvalidName(#[from] ValidateNameError),
363    #[error("Invalid endpoint group: {0}")]
364    InvalidGroup(ValidateNameError),
365    #[error("Invalid endpoint attributes: {0}")]
366    InvalidAttributes(#[from] ValidateAttributesError),
367}
368
369impl From<irpc::Error> for BuildError {
370    fn from(value: irpc::Error) -> Self {
371        match value {
372            irpc::Error::Request {
373                source:
374                    irpc::RequestError::Connection {
375                        source: iroh::endpoint::ConnectionError::ApplicationClosed(frame),
376                        ..
377                    },
378                ..
379            } if frame.error_code == 401u32.into() => Self::Unauthorized,
380            value => Self::Rpc(value),
381        }
382    }
383}
384
385/// How long [`Client::shutdown`] lets a request already in flight finish.
386///
387/// Dropping a request mid-flight resets its stream, and the server treats that
388/// as the connection failing: it stops reading requests and tears the
389/// connection down. Letting the request finish keeps the connection usable for
390/// the final metrics push. Requests to a responsive server complete in
391/// fast, so this only runs out when the server has stopped answering,
392/// in which case the final metrics push is then skipped.
393const SHUTDOWN_GRACE: Duration = Duration::from_secs(3);
394
395/// How long [`Client::shutdown`] waits for the final metrics push.
396///
397/// The push goes over an established connection, so it is normally well under
398/// this. The bound covers a peer that has gone silent without closing, where
399/// the request would otherwise hang until the connection times out.
400const SHUTDOWN_FLUSH_TIMEOUT: Duration = Duration::from_secs(2);
401
402/// Minimum length in bytes for an endpoint name.
403pub const CLIENT_NAME_MIN_LENGTH: usize = 2;
404/// Maximum length in bytes for an endpoint name.
405pub const CLIENT_NAME_MAX_LENGTH: usize = 128;
406
407/// Error returned when an endpoint name fails validation.
408#[derive(Debug, thiserror::Error)]
409pub enum ValidateNameError {
410    #[error("Name is too long (must be no more than {CLIENT_NAME_MAX_LENGTH} bytes).")]
411    TooLong,
412    #[error("Name is too short (must be at least {CLIENT_NAME_MIN_LENGTH} bytes).")]
413    TooShort,
414}
415
416fn validate_name(name: &str) -> Result<(), ValidateNameError> {
417    if name.len() < CLIENT_NAME_MIN_LENGTH {
418        Err(ValidateNameError::TooShort)
419    } else if name.len() > CLIENT_NAME_MAX_LENGTH {
420        Err(ValidateNameError::TooLong)
421    } else {
422        Ok(())
423    }
424}
425
426/// Error returned when an attributes map fails validation.
427#[derive(Debug, thiserror::Error)]
428pub enum ValidateAttributesError {
429    #[error("Too many attributes (must be no more than {ATTRIBUTES_MAX_COUNT}).")]
430    TooManyEntries,
431    #[error("Invalid attribute key: {0}")]
432    InvalidKey(#[from] ValidateNameError),
433    #[error("Attribute value too long (must be no more than {ATTRIBUTE_VALUE_MAX_LENGTH} bytes).")]
434    ValueTooLong,
435}
436
437fn validate_attributes(attrs: &BTreeMap<String, String>) -> Result<(), ValidateAttributesError> {
438    if attrs.len() > ATTRIBUTES_MAX_COUNT {
439        return Err(ValidateAttributesError::TooManyEntries);
440    }
441    for (k, v) in attrs {
442        validate_name(k)?;
443        if v.len() > ATTRIBUTE_VALUE_MAX_LENGTH {
444            return Err(ValidateAttributesError::ValueTooLong);
445        }
446    }
447    Ok(())
448}
449
450#[derive(thiserror::Error, Debug)]
451#[non_exhaustive]
452pub enum Error {
453    #[error("Invalid endpoint name: {0}")]
454    InvalidName(#[from] ValidateNameError),
455    #[error("Invalid endpoint group: {0}")]
456    InvalidGroup(ValidateNameError),
457    #[error("Invalid endpoint attributes: {0}")]
458    InvalidAttributes(#[from] ValidateAttributesError),
459    #[error("Remote error: {0}")]
460    Remote(#[from] RemoteError),
461    #[error("Connection error: {0}")]
462    Connect(#[from] ConnectError),
463    #[error("Rpc error: {0}")]
464    Rpc(#[from] irpc::Error),
465    #[error(transparent)]
466    Other(#[from] anyhow::Error),
467    #[error("Local client actor is stopped, cannot send requests")]
468    ActorStopped,
469}
470
471impl From<tokio::sync::mpsc::error::SendError<ClientActorMessage>> for Error {
472    fn from(_value: tokio::sync::mpsc::error::SendError<ClientActorMessage>) -> Self {
473        Error::ActorStopped
474    }
475}
476
477impl From<tokio::sync::oneshot::error::RecvError> for Error {
478    fn from(_value: tokio::sync::oneshot::error::RecvError) -> Self {
479        Error::ActorStopped
480    }
481}
482
483impl Client {
484    pub fn builder(endpoint: &Endpoint) -> ClientBuilder {
485        ClientBuilder::new(endpoint)
486    }
487
488    /// Read the current endpoint name from the local client.
489    pub async fn name(&self) -> Result<Option<String>, Error> {
490        let (tx, rx) = oneshot::channel();
491        self.message_channel
492            .send(ClientActorMessage::ReadName { done: tx })
493            .await?;
494        rx.await.map_err(Into::into)
495    }
496
497    /// Read the current endpoint group from the local client.
498    pub async fn group(&self) -> Result<Option<String>, Error> {
499        let (tx, rx) = oneshot::channel();
500        self.message_channel
501            .send(ClientActorMessage::ReadGroup { done: tx })
502            .await?;
503        rx.await.map_err(Into::into)
504    }
505
506    /// Name the active endpoint cloud-side.
507    ///
508    /// names can be any UTF-8 string, with a min length of 2 bytes, and
509    /// maximum length of 128 bytes. **name uniqueness is not enforced.**
510    pub async fn set_name(&self, name: impl Into<String>) -> Result<(), Error> {
511        let name = name.into();
512        validate_name(&name)?;
513        debug!(name_len = name.len(), "calling set name");
514        let (tx, rx) = oneshot::channel();
515        self.message_channel
516            .send(ClientActorMessage::NameEndpoint { name, done: tx })
517            .await?;
518        rx.await?
519    }
520
521    /// Attach the active endpoint to a single named group cloud-side.
522    ///
523    /// A group name must be 2 to 128 bytes of UTF-8.
524    pub async fn set_group(&self, group: impl Into<String>) -> Result<(), Error> {
525        let group: String = group.into();
526        validate_name(&group).map_err(Error::InvalidGroup)?;
527        debug!(%group, "calling set group");
528        let (tx, rx) = oneshot::channel();
529        self.message_channel
530            .send(ClientActorMessage::SetGroup { group, done: tx })
531            .await?;
532        rx.await?
533    }
534
535    /// Replace the arbitrary key-value attributes on the active endpoint cloud-side.
536    ///
537    /// Accepts any iterable of `(key, value)` pairs (arrays of tuples, `Vec`s,
538    /// `HashMap`s, `BTreeMap`s, etc.), so most calls fit on a single line:
539    ///
540    /// ```no_run
541    /// # use iroh_services::Client;
542    /// # async fn example(client: Client) -> anyhow::Result<()> {
543    /// client
544    ///     .set_attributes([("env", "prod"), ("region", "us-west")])
545    ///     .await?;
546    /// # Ok(()) }
547    /// ```
548    ///
549    /// Each key must be 2 to 128 bytes of UTF-8; values may be empty and are limited
550    /// to 128 bytes; at most 128 entries are allowed. Each call fully replaces
551    /// the prior set; passing an empty iterator clears all attributes.
552    pub async fn set_attributes<I, K, V>(&self, attrs: I) -> Result<(), Error>
553    where
554        I: IntoIterator<Item = (K, V)>,
555        K: Into<String>,
556        V: Into<String>,
557    {
558        let collected: BTreeMap<String, String> = attrs
559            .into_iter()
560            .map(|(k, v)| (k.into(), v.into()))
561            .collect();
562        validate_attributes(&collected)?;
563        debug!(attr_count = collected.len(), "calling set attributes");
564        let (tx, rx) = oneshot::channel();
565        self.message_channel
566            .send(ClientActorMessage::SetAttributes {
567                attributes: collected,
568                done: tx,
569            })
570            .await?;
571        rx.await?
572    }
573
574    /// Set or replace a single attribute, merging it into the endpoint's existing
575    /// attributes rather than replacing the whole set.
576    ///
577    /// A convenience over [`set_attributes`](Self::set_attributes) when you only
578    /// need to change one value. The key must be 2 to 128 bytes of UTF-8 and the
579    /// value is limited to 128 bytes; the merged set must stay within 128 entries.
580    pub async fn set_attribute(
581        &self,
582        key: impl Into<String>,
583        value: impl Into<String>,
584    ) -> Result<(), Error> {
585        // Validation happens in the actor against the merged set (current
586        // attributes plus this entry), since only there is the current set
587        // known. Merging can exceed the entry-count limit even when this
588        // single entry is valid.
589        let (tx, rx) = oneshot::channel();
590        self.message_channel
591            .send(ClientActorMessage::SetAttribute {
592                key: key.into(),
593                value: value.into(),
594                done: tx,
595            })
596            .await?;
597        rx.await?
598    }
599
600    /// Pings the remote node.
601    pub async fn ping(&self) -> Result<Pong, Error> {
602        let (tx, rx) = oneshot::channel();
603        self.message_channel
604            .send(ClientActorMessage::Ping { done: tx })
605            .await?;
606        rx.await?
607    }
608
609    /// immediately send a single dump of metrics to iroh-services. It's not necessary
610    /// to call this function if you're using a non-zero metrics interval,
611    /// which will automatically propagate metrics on the set interval for you
612    pub async fn push_metrics(&self) -> Result<(), Error> {
613        let (tx, rx) = oneshot::channel();
614        self.message_channel
615            .send(ClientActorMessage::SendMetrics { done: tx })
616            .await?;
617        rx.await?
618    }
619
620    /// Shuts down the client, after pushing one final round of metrics.
621    ///
622    /// Calling this aborts any inflight requests, and all subsequent requests
623    /// triggered via methods on [`Self`] will fail. If the client is currently
624    /// connected, it will send one final metrics update before shutting down
625    /// the client actor.
626    ///
627    /// Dropping the client without calling this method immediately stops the
628    /// actor without a final metrics push.
629    pub async fn shutdown(&self) {
630        self.shutdown.cancel();
631        // The actor drops the inbox receiver on its way out.
632        self.message_channel.closed().await;
633    }
634
635    /// Grant capabilities to a remote endpoint. Creates a signed RCAN token
636    /// and sends it to iroh-services for storage. The remote can then use this token
637    /// when dialing back to authorize its requests.
638    pub async fn grant_capability(&self, remote_id: EndpointId, caps: Caps) -> Result<(), Error> {
639        let cap = crate::caps::create_grant_token(
640            self.endpoint.secret_key().clone(),
641            remote_id,
642            DEFAULT_CAP_EXPIRY,
643            caps,
644        )
645        .map_err(Error::Other)?
646        .into_rcan();
647
648        let (tx, rx) = oneshot::channel();
649        self.message_channel
650            .send(ClientActorMessage::GrantCap {
651                cap: Box::new(cap),
652                done: tx,
653            })
654            .await?;
655        rx.await?
656    }
657
658    /// run local network status diagnostics, optionally uploading the results
659    pub async fn net_diagnostics(&self, send: bool) -> Result<DiagnosticsReport, Error> {
660        let report = run_diagnostics(&self.endpoint).await?;
661        if send {
662            let (tx, rx) = oneshot::channel();
663            self.message_channel
664                .send(ClientActorMessage::PutNetworkDiagnostics {
665                    done: tx,
666                    report: Box::new(report.clone()),
667                })
668                .await?;
669            rx.await??;
670        }
671
672        Ok(report)
673    }
674}
675
676enum ClientActorMessage {
677    SendMetrics {
678        done: oneshot::Sender<Result<(), Error>>,
679    },
680    Ping {
681        done: oneshot::Sender<Result<Pong, Error>>,
682    },
683    GrantCap {
684        // boxed to avoid large enum variants
685        cap: Box<Rcan<ProtoCaps>>,
686        done: oneshot::Sender<Result<(), Error>>,
687    },
688    PutNetworkDiagnostics {
689        report: Box<DiagnosticsReport>,
690        done: oneshot::Sender<Result<(), Error>>,
691    },
692    ReadName {
693        done: oneshot::Sender<Option<String>>,
694    },
695    ReadGroup {
696        done: oneshot::Sender<Option<String>>,
697    },
698    NameEndpoint {
699        name: String,
700        done: oneshot::Sender<Result<(), Error>>,
701    },
702    SetGroup {
703        group: String,
704        done: oneshot::Sender<Result<(), Error>>,
705    },
706    SetAttributes {
707        attributes: BTreeMap<String, String>,
708        done: oneshot::Sender<Result<(), Error>>,
709    },
710    SetAttribute {
711        key: String,
712        value: String,
713        // Carries the full client `Error` (not just `RemoteError`) because the
714        // merged-set validation happens in the actor, where the current set is
715        // known, and can fail with a local `InvalidAttributes` error.
716        done: oneshot::Sender<Result<(), Error>>,
717    },
718}
719
720/// Whether an rpc error means the connection can no longer carry requests.
721///
722/// Most of these are the stream pair or the connection itself failing, so the
723/// connection has to go. An oversized message is the exception: irpc rejects it
724/// locally before anything reaches the wire, so the connection is still fine.
725/// Re-dialing on it would pay for a handshake and a full metrics schema resend
726/// only to hit the same limit on the retry.
727fn is_connection_lost(err: &irpc::Error) -> bool {
728    !matches!(
729        err,
730        irpc::Error::Send {
731            source: irpc::channel::SendError::MaxMessageSizeExceeded { .. },
732            ..
733        }
734    )
735}
736
737/// An irpc client bound to a single authenticated connection.
738///
739/// The server accepts requests only after an `Auth` as the very first
740/// request on a connection, so [`RpcClient::connect`] dials and
741/// authenticates in one step; a value of this type never exists
742/// unauthenticated.
743struct RpcClient {
744    /// Kept alongside the irpc client to detect a remote close.
745    connection: Connection,
746    irpc: IrohServicesClient,
747}
748
749impl RpcClient {
750    /// Dials the remote and authenticates as the connection's first request.
751    async fn connect(
752        endpoint: &Endpoint,
753        remote: EndpointAddr,
754        caps: Rcan<ProtoCaps>,
755    ) -> Result<Self, Error> {
756        trace!("client connecting and authorizing");
757        let connection = endpoint
758            .connect(remote, ALPN)
759            .await
760            .inspect_err(|err| debug!("connect failed: {err:?}"))?;
761        let irpc = IrohServicesClient::boxed(IrohRemoteConnection::new(connection.clone()));
762        irpc.rpc(Auth { caps })
763            .await
764            .inspect_err(|err| debug!("authorization failed: {err:?}"))
765            .map_err(|err| Error::Remote(RemoteError::AuthError(err.to_string())))?;
766        Ok(Self { connection, irpc })
767    }
768}
769
770struct ClientActor {
771    capabilities: Rcan<ProtoCaps>,
772    endpoint: Endpoint,
773    remote: EndpointAddr,
774    /// The active authenticated connection, established on demand.
775    ///
776    /// The actor owns the connection lifecycle rather than using a lazy
777    /// reconnecting client: the server requires `Auth` as the first request
778    /// on every connection, so a transparent mid-request reconnect would send
779    /// an unauthenticated request that the server rejects. [`Self::connect`]
780    /// establishes this; [`Self::rpc`] clears it on transport errors so the
781    /// next request re-dials and re-authenticates.
782    client: Option<RpcClient>,
783    name: Option<String>,
784    group: Option<String>,
785    attributes: BTreeMap<String, String>,
786    session_id: Uuid,
787    encoder: Encoder,
788    /// Kept so connect() can rebuild the encoder to re-send the metrics schema.
789    registry: Arc<RwLock<Registry>>,
790}
791
792impl ClientActor {
793    /// Runs the actor until the inbox closes or `shutdown` is cancelled, then
794    /// pushes one final round of metrics.
795    ///
796    /// Cancelling `shutdown` drops whatever [`Self::run_inner`] is awaiting,
797    /// including all in-flight or queued requests. We do this so that a
798    /// pending dial does not hold up shutdown.
799    async fn run(
800        mut self,
801        interval: Option<Duration>,
802        mut inbox: tokio::sync::mpsc::Receiver<ClientActorMessage>,
803        shutdown: CancellationToken,
804    ) {
805        let metrics_enabled = interval.is_some();
806        let shutdown_and_grace_period_expired = async {
807            shutdown.cancelled().await;
808            time::sleep(SHUTDOWN_GRACE).await;
809        };
810
811        let clean_shutdown = tokio::select! {
812            () = self.run_inner(interval, &mut inbox, &shutdown) => true,
813            () = shutdown_and_grace_period_expired => {
814                debug!("shutdown grace elapsed, dropping the request in flight");
815                false
816            }
817        };
818
819        // Flush only over a connection that already exists. Dialing here would
820        // reintroduce the stall that cancelling just avoided, and an endpoint
821        // that never connected has nothing the server could attribute.
822        if clean_shutdown && metrics_enabled && self.is_connected() {
823            match time::timeout(SHUTDOWN_FLUSH_TIMEOUT, self.send_metrics()).await {
824                Ok(Ok(())) => trace!("pushed final metrics on shutdown"),
825                Ok(Err(err)) => debug!(%err, "failed to push final metrics on shutdown"),
826                Err(_) => debug!("final metrics push on shutdown timed out"),
827            }
828        }
829        debug!("client actor shut down");
830    }
831
832    async fn run_inner(
833        &mut self,
834        interval: Option<Duration>,
835        inbox: &mut tokio::sync::mpsc::Receiver<ClientActorMessage>,
836        shutdown: &CancellationToken,
837    ) {
838        let mut metrics_timer = interval.map(|interval| time::interval(interval));
839        trace!("starting client actor");
840
841        // Send the initial metadata (set via the builder) once the actor starts.
842        // These live on `self`; a send failure here is logged, not fatal.
843        if let Some(name) = self.name.clone()
844            && let Err(err) = self.send_name_endpoint(name).await
845        {
846            warn!(err = %err, "failed setting endpoint name on startup");
847        }
848
849        if let Some(group) = self.group.clone()
850            && let Err(err) = self.send_set_group(group).await
851        {
852            warn!(err = %err, "failed setting endpoint group on startup");
853        }
854
855        if !self.attributes.is_empty()
856            && let Err(err) = self.send_set_attributes(self.attributes.clone()).await
857        {
858            warn!(err = %err, "failed setting endpoint attributes on startup");
859        }
860
861        loop {
862            trace!("client actor tick");
863            tokio::select! {
864                biased;
865                // Shutdown is only observed between requests: a branch body below
866                // runs to completion, so a request already in flight finishes first.
867                () = shutdown.cancelled() => {
868                    trace!("client actor observed shutdown between requests");
869                    break;
870                }
871                Some(msg) = inbox.recv() => {
872                    match msg {
873                        ClientActorMessage::Ping { done } => {
874                            let res = self.send_ping().await;
875                            done.send(res).ok();
876                        },
877                        ClientActorMessage::SendMetrics { done } => {
878                            trace!("sending metrics manually triggered");
879                            let res = self.send_metrics().await;
880                            done.send(res).ok();
881                        }
882                        ClientActorMessage::GrantCap { cap, done } => {
883                            let res = self.grant_cap(*cap).await;
884                            done.send(res).ok();
885                        }
886                        ClientActorMessage::ReadName { done } => {
887                            done.send(self.name.clone()).ok();
888                        }
889                        ClientActorMessage::ReadGroup { done } => {
890                            done.send(self.group.clone()).ok();
891                        }
892                        ClientActorMessage::NameEndpoint { name, done } => {
893                            let res = self.send_name_endpoint(name).await;
894                            done.send(res).ok();
895                        }
896                        ClientActorMessage::SetGroup { group, done } => {
897                            let res = self.send_set_group(group).await;
898                            done.send(res).ok();
899                        }
900                        ClientActorMessage::SetAttributes { attributes, done } => {
901                            let res = self.send_set_attributes(attributes).await;
902                            done.send(res).ok();
903                        }
904                        ClientActorMessage::SetAttribute { key, value, done } => {
905                            // Merge into the current set and validate the union:
906                            // adding one valid entry to a valid set can still
907                            // exceed the max entry count, so the single entry
908                            // being valid is not enough.
909                            let mut merged = self.attributes.clone();
910                            merged.insert(key, value);
911                            let res = match validate_attributes(&merged) {
912                                Ok(()) => self.send_set_attributes(merged).await,
913                                Err(err) => Err(Error::from(err)),
914                            };
915                            done.send(res).ok();
916                        }
917                        ClientActorMessage::PutNetworkDiagnostics { report, done } => {
918                            let res = self.put_network_diagnostics(*report).await;
919                            done.send(res).ok();
920                        }
921                    }
922                }
923                _ = async {
924                    if let Some(ref mut timer) = metrics_timer {
925                        timer.tick().await;
926                    } else {
927                        std::future::pending::<()>().await;
928                    }
929                } => {
930                    trace!("metrics send tick");
931                    if let Err(err) = self.send_metrics().await {
932                        debug!("failed to push metrics: {:#?}", err);
933                    }
934                },
935            }
936        }
937    }
938
939    /// Whether a connection is established and still usable.
940    ///
941    /// Distinct from holding a [`RpcClient`]: the remote may have closed the
942    /// connection since, in which case sending over it would have to re-dial.
943    fn is_connected(&self) -> bool {
944        self.client
945            .as_ref()
946            .is_some_and(|client| client.connection.close_reason().is_none())
947    }
948
949    /// Returns the client for the active connection, establishing one if needed.
950    ///
951    /// The server keeps one metrics decoder per connection, so a fresh
952    /// connection also gets a fresh encoder: the next metrics export then
953    /// carries the full schema for the server's fresh decoder.
954    ///
955    /// # Errors
956    ///
957    /// Returns an error when the dial, or the authentication that follows it,
958    /// fails. A connection is stored only once both have succeeded, and a
959    /// connection found closed is cleared before either is attempted, so a
960    /// caller that sees an error has nothing left to clean up.
961    async fn connect(&mut self) -> Result<&IrohServicesClient, Error> {
962        // A connection the remote has closed (server restart, error close)
963        // can't carry requests anymore; drop it and re-dial.
964        if let Some(client) = &self.client
965            && let Some(reason) = client.connection.close_reason()
966        {
967            debug!(%reason, "connection closed by remote, reconnecting");
968            self.client = None;
969        }
970        // Taking the connection out and putting it back lets `insert` hand out
971        // a borrow tied to the value it just stored. Checking `is_none` and
972        // then looking the value up again needs an `expect`, because the
973        // compiler cannot see that the lookup follows the store.
974        let client = match self.client.take() {
975            Some(client) => client,
976            None => {
977                let client = RpcClient::connect(
978                    &self.endpoint,
979                    self.remote.clone(),
980                    self.capabilities.clone(),
981                )
982                .await?;
983                // Recreate the metrics encoder to force sending the schema on the next metrics push.
984                self.encoder = Encoder::new(self.registry.clone());
985                client
986            }
987        };
988        Ok(&self.client.insert(client).irpc)
989    }
990
991    async fn rpc<Req, Res>(&mut self, msg: Req) -> Result<Res, Error>
992    where
993        IrohServicesProtocol: From<Req>,
994        ServicesMessage: From<WithChannels<Req, IrohServicesProtocol>>,
995        Req: Channels<
996                IrohServicesProtocol,
997                Tx = irpc::channel::oneshot::Sender<Res>,
998                Rx = NoReceiver,
999            > + Display,
1000        Res: RpcMessage,
1001    {
1002        trace!(request = %msg, "client actor send request");
1003        let client = self.connect().await?;
1004        let res = client.rpc(msg).await;
1005
1006        if let Err(err) = &res
1007            && is_connection_lost(err)
1008        {
1009            // The connection is gone or in an unknown state. Clear the client
1010            // so that the next request dials and authenticates.
1011            self.client = None;
1012        }
1013
1014        res.inspect_err(|err| warn!("rpc error: {err}"))
1015            .map_err(Error::from)
1016    }
1017
1018    async fn send_ping(&mut self) -> Result<Pong, Error> {
1019        let req = rand::random();
1020        let pong: ProtoPong = self.rpc(Ping { req_id: req }).await?;
1021        Ok(Pong {
1022            req_id: pong.req_id,
1023        })
1024    }
1025
1026    async fn send_name_endpoint(&mut self, name: String) -> Result<(), Error> {
1027        self.rpc(NameEndpoint { name: name.clone() })
1028            .await?
1029            .map_err(RemoteError::from_proto)?;
1030        self.name = Some(name);
1031        Ok(())
1032    }
1033
1034    async fn send_set_group(&mut self, group: String) -> Result<(), Error> {
1035        self.rpc(SetGroup {
1036            group: group.clone(),
1037        })
1038        .await?
1039        .map_err(RemoteError::from_proto)?;
1040        self.group = Some(group);
1041        Ok(())
1042    }
1043
1044    async fn send_set_attributes(
1045        &mut self,
1046        attributes: BTreeMap<String, String>,
1047    ) -> Result<(), Error> {
1048        self.rpc(SetAttributes {
1049            attributes: attributes.clone(),
1050        })
1051        .await?
1052        .map_err(RemoteError::from_proto)?;
1053        self.attributes = attributes;
1054        Ok(())
1055    }
1056
1057    async fn send_metrics(&mut self) -> Result<(), Error> {
1058        // Connecting replaces the encoder, so it must happen before the
1059        // export: the first update on a fresh connection has to carry the
1060        // schema for the server's fresh decoder.
1061        self.connect().await?;
1062        let update = self.encoder.export();
1063        // let delta = update_delta(&self.latest_ackd_update, &update);
1064        let req = PutMetrics {
1065            session_id: self.session_id,
1066            update,
1067        };
1068        self.rpc(req).await?.map_err(RemoteError::from_proto)?;
1069        Ok(())
1070    }
1071
1072    async fn grant_cap(&mut self, cap: Rcan<ProtoCaps>) -> Result<(), Error> {
1073        self.rpc(GrantCap { cap })
1074            .await?
1075            .map_err(RemoteError::from_proto)?;
1076        Ok(())
1077    }
1078
1079    async fn put_network_diagnostics(&mut self, report: DiagnosticsReport) -> Result<(), Error> {
1080        self.rpc(PutNetworkDiagnostics {
1081            report: report.into_proto(),
1082        })
1083        .await?
1084        .map_err(RemoteError::from_proto)?;
1085        Ok(())
1086    }
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091    use std::{
1092        collections::HashMap,
1093        sync::{
1094            Arc, RwLock,
1095            atomic::{AtomicBool, Ordering},
1096        },
1097    };
1098
1099    use iroh::{
1100        Endpoint, EndpointAddr, SecretKey,
1101        endpoint::{Connection, presets},
1102        protocol::{AcceptError, ProtocolHandler, Router},
1103    };
1104    use iroh_metrics::{
1105        Registry,
1106        encoding::{Decoder, Encoder},
1107    };
1108    use iroh_services_proto::{IrohServicesProtocol, Pong, ServicesMessage};
1109    use irpc::WithChannels;
1110    use irpc_iroh::read_request;
1111    use n0_error::AnyError;
1112    use n0_future::{
1113        task,
1114        time::{self, Duration},
1115    };
1116    use rand::{RngExt, SeedableRng};
1117    use temp_env_vars::temp_env_vars;
1118
1119    use crate::{
1120        Client, ClientBuilder,
1121        api_secret::ApiSecret,
1122        caps::Caps,
1123        client::{
1124            API_SECRET_ENV_VAR_NAME, ATTRIBUTE_VALUE_MAX_LENGTH, ATTRIBUTES_MAX_COUNT, BuildError,
1125            CLIENT_NAME_MAX_LENGTH, Error, ValidateAttributesError, ValidateNameError,
1126            is_connection_lost,
1127        },
1128    };
1129
1130    /// What the test server recorded about one PutMetrics request.
1131    #[derive(Debug)]
1132    struct SeenUpdate {
1133        has_schema: bool,
1134        decoded_items: usize,
1135    }
1136
1137    /// What the test server recorded, in arrival order.
1138    #[derive(Debug)]
1139    enum Seen {
1140        Metrics(SeenUpdate),
1141        /// A Ping whose response reached the client without the stream failing.
1142        PingAnswered,
1143    }
1144
1145    /// In-process stand-in for the services backend.
1146    ///
1147    /// Mirrors the real server's session rules: the first request on every
1148    /// connection must be Auth, a repeat Auth on a live connection closes it,
1149    /// and the metrics decoder lives per connection. Requests are read one at
1150    /// a time and any error ends the connection, as in the backend, so a
1151    /// client that drops a request mid-flight takes the connection with it.
1152    #[derive(Debug)]
1153    struct TestServer {
1154        seen: tokio::sync::mpsc::UnboundedSender<Seen>,
1155        /// Kills the next connection without answering, simulating a restart.
1156        drop_next: Arc<AtomicBool>,
1157        /// Held before answering a Ping, so a test can catch one in flight.
1158        ping_delay: Duration,
1159    }
1160
1161    impl TestServer {
1162        fn new(seen: tokio::sync::mpsc::UnboundedSender<Seen>) -> Self {
1163            Self {
1164                seen,
1165                drop_next: Arc::new(AtomicBool::new(false)),
1166                ping_delay: Duration::ZERO,
1167            }
1168        }
1169
1170        fn ping_delay(mut self, delay: Duration) -> Self {
1171            self.ping_delay = delay;
1172            self
1173        }
1174
1175        async fn handle_connection(&self, connection: Connection) -> anyhow::Result<()> {
1176            let Some(first_request) = read_request::<IrohServicesProtocol>(&connection).await?
1177            else {
1178                return Ok(());
1179            };
1180            let ServicesMessage::Auth(WithChannels { tx, .. }) = first_request else {
1181                connection.close(400u32.into(), b"Expected initial auth message");
1182                return Ok(());
1183            };
1184            tx.send(()).await?;
1185
1186            let mut decoder = Decoder::default();
1187            loop {
1188                let Ok(Some(request)) = read_request::<IrohServicesProtocol>(&connection).await
1189                else {
1190                    return Ok(());
1191                };
1192                if self.drop_next.swap(false, Ordering::SeqCst) {
1193                    // Simulates a server restart: the connection dies without an
1194                    // answer and takes the per-connection decoder with it.
1195                    connection.close(500u32.into(), b"test restart");
1196                    return Ok(());
1197                }
1198                match request {
1199                    ServicesMessage::Auth(_) => {
1200                        connection.close(400u32.into(), b"Unexpected auth message");
1201                        anyhow::bail!("client re-sent auth on a live connection");
1202                    }
1203                    ServicesMessage::Ping(WithChannels { inner, tx, .. }) => {
1204                        time::sleep(self.ping_delay).await;
1205                        // A client that dropped this request has reset the
1206                        // stream, and this send is where the server finds out.
1207                        tx.send(Pong {
1208                            req_id: inner.req_id,
1209                        })
1210                        .await?;
1211                        let _ = self.seen.send(Seen::PingAnswered);
1212                    }
1213                    ServicesMessage::PutMetrics(WithChannels { inner, tx, .. }) => {
1214                        let has_schema = inner.update.schema.is_some();
1215                        decoder.import(inner.update);
1216                        let _ = self.seen.send(Seen::Metrics(SeenUpdate {
1217                            has_schema,
1218                            decoded_items: decoder.iter().count(),
1219                        }));
1220                        tx.send(Ok(())).await?;
1221                    }
1222                    _ => {
1223                        connection.close(400u32.into(), b"Unexpected message in test");
1224                        anyhow::bail!("unexpected message in test");
1225                    }
1226                }
1227            }
1228        }
1229    }
1230
1231    impl ProtocolHandler for TestServer {
1232        async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
1233            self.handle_connection(connection).await.map_err(|e| {
1234                let boxed: Box<dyn std::error::Error + Send + Sync> = e.into();
1235                AcceptError::from(AnyError::from(boxed))
1236            })
1237        }
1238    }
1239
1240    /// Spawns `server` on its own endpoint and returns a client builder aimed
1241    /// at it, with the router and the client endpoint for teardown.
1242    ///
1243    /// The metrics interval is left to the caller, since that is what the
1244    /// tests vary. The test server accepts any capability, so a self-issued
1245    /// token works.
1246    async fn spawn_test_server(seed: u64, server: TestServer) -> (Router, Endpoint, ClientBuilder) {
1247        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(seed);
1248        let server_ep = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1249        let client_ep = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1250        let router = Router::builder(server_ep.clone())
1251            .accept(crate::ALPN, server)
1252            .spawn();
1253
1254        let shared_secret = SecretKey::from_bytes(&rng.random());
1255        let api_secret = ApiSecret::new(shared_secret, server_ep.id());
1256        let builder = Client::builder(&client_ep)
1257            .api_secret(api_secret)
1258            .unwrap()
1259            .remote(server_ep.addr());
1260        (router, client_ep, builder)
1261    }
1262
1263    /// Awaits the next metrics push the server recorded.
1264    async fn next_metrics(rx: &mut tokio::sync::mpsc::UnboundedReceiver<Seen>) -> SeenUpdate {
1265        match rx.recv().await.expect("server dropped the record channel") {
1266            Seen::Metrics(update) => update,
1267            other => panic!("expected a metrics push, recorded {other:?}"),
1268        }
1269    }
1270
1271    /// Takes everything the server has recorded so far, without waiting.
1272    fn recorded_so_far(rx: &mut tokio::sync::mpsc::UnboundedReceiver<Seen>) -> Vec<Seen> {
1273        let mut seen = Vec::new();
1274        while let Ok(record) = rx.try_recv() {
1275            seen.push(record);
1276        }
1277        seen
1278    }
1279
1280    /// A reconnect must make the next metrics update carry the schema.
1281    ///
1282    /// The server holds one decoder per connection, so the first update after
1283    /// a re-auth is undecodable unless the schema comes along again.
1284    #[tokio::test]
1285    async fn test_metrics_schema_resent_after_reconnect() {
1286        let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel();
1287        let server = TestServer::new(seen_tx);
1288        let drop_next = server.drop_next.clone();
1289        let (router, client_ep, builder) = spawn_test_server(2, server).await;
1290
1291        let client = builder.disable_metrics_interval().build().await.unwrap();
1292
1293        // The first export on a fresh session carries the schema.
1294        client.push_metrics().await.unwrap();
1295        let first = next_metrics(&mut seen_rx).await;
1296        assert!(first.has_schema);
1297        assert!(first.decoded_items > 0);
1298
1299        // Steady state stops sending the schema. The endpoint's own metric
1300        // families may still grow for a few exports right after connecting
1301        // (each growth re-publishes the schema), so push until the schema
1302        // settles; the connection's decoder keeps decoding throughout.
1303        let mut settled = false;
1304        for _ in 0..20 {
1305            client.push_metrics().await.unwrap();
1306            let seen = next_metrics(&mut seen_rx).await;
1307            assert!(seen.decoded_items > 0);
1308            if !seen.has_schema {
1309                settled = true;
1310                break;
1311            }
1312        }
1313        assert!(settled, "schema must stop being sent once it is unchanged");
1314
1315        // The server drops the connection mid-request: one failed round trip.
1316        drop_next.store(true, Ordering::SeqCst);
1317        assert!(client.push_metrics().await.is_err());
1318
1319        // The client re-dials and re-auths; the first update on the new
1320        // connection must include the schema for the server's fresh decoder.
1321        client.push_metrics().await.unwrap();
1322        let third = next_metrics(&mut seen_rx).await;
1323        assert!(third.has_schema, "schema must be re-sent after a reconnect");
1324        assert!(third.decoded_items > 0);
1325
1326        router.shutdown().await.unwrap();
1327        client_ep.close().await;
1328    }
1329
1330    /// Documents the encoder and decoder contract the reconnect fix relies on.
1331    #[tokio::test]
1332    async fn test_fresh_encoder_resends_schema() {
1333        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1334        let mut registry = Registry::default();
1335        registry.register_all(endpoint.metrics());
1336        let registry = Arc::new(RwLock::new(registry));
1337
1338        let mut encoder = Encoder::new(registry.clone());
1339        let first = encoder.export();
1340        assert!(first.schema.is_some());
1341
1342        // Steady state omits the schema once it has been exported.
1343        let schemaless = encoder.export();
1344        assert!(schemaless.schema.is_none());
1345
1346        // A decoder that never saw the schema decodes such an update to
1347        // nothing: this is the server side of the reconnect bug.
1348        let mut fresh_decoder = Decoder::default();
1349        fresh_decoder.import(schemaless);
1350        assert_eq!(fresh_decoder.iter().count(), 0);
1351
1352        // A new encoder over the same registry starts at schema version zero
1353        // and re-publishes the schema, which is what auth() does on re-auth.
1354        let mut encoder = Encoder::new(registry);
1355        let resent = encoder.export();
1356        assert!(resent.schema.is_some());
1357        let mut fresh_decoder = Decoder::default();
1358        fresh_decoder.import(resent);
1359        assert!(fresh_decoder.iter().count() > 0);
1360    }
1361
1362    #[tokio::test]
1363    #[temp_env_vars]
1364    async fn test_api_key_from_env() {
1365        // construct
1366        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(0);
1367        let shared_secret = SecretKey::from_bytes(&rng.random());
1368        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1369        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
1370        unsafe {
1371            std::env::set_var(API_SECRET_ENV_VAR_NAME, api_secret.to_string());
1372        };
1373
1374        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1375
1376        let builder = Client::builder(&endpoint).api_secret_from_env().unwrap();
1377
1378        let fake_endpoint_addr: EndpointAddr = fake_endpoint_id.into();
1379        assert_eq!(builder.remote, Some(fake_endpoint_addr));
1380
1381        // Compare capability fields individually to avoid flaky timestamp
1382        // mismatches between the builder's rcan and a freshly-created one.
1383        let cap = builder.cap.as_ref().expect("expected capability to be set");
1384        assert_eq!(cap.capability(), &Caps::client().0);
1385        assert_eq!(cap.audience(), &endpoint.id().as_verifying_key());
1386        assert_eq!(cap.issuer(), &shared_secret.public().as_verifying_key());
1387    }
1388
1389    /// Assert that disabling metrics interval can manually send metrics without
1390    /// panicking. Metrics sending itself is expected to fail.
1391    #[tokio::test]
1392    async fn test_no_metrics_interval() {
1393        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(1);
1394        let shared_secret = SecretKey::from_bytes(&rng.random());
1395        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1396        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
1397
1398        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1399
1400        let client = Client::builder(&endpoint)
1401            .disable_metrics_interval()
1402            .api_secret(api_secret)
1403            .unwrap()
1404            .build()
1405            .await
1406            .unwrap();
1407
1408        let err = client.push_metrics().await;
1409        assert!(err.is_err());
1410    }
1411
1412    /// Assert that shutdown returns even when the final metrics push fails,
1413    /// and that the actor is stopped afterwards.
1414    #[tokio::test]
1415    async fn test_shutdown_stops_actor() {
1416        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(2);
1417        let shared_secret = SecretKey::from_bytes(&rng.random());
1418        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1419        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
1420
1421        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1422
1423        let client = Client::builder(&endpoint)
1424            .api_secret(api_secret)
1425            .unwrap()
1426            .build()
1427            .await
1428            .unwrap();
1429
1430        client.shutdown().await;
1431
1432        // the actor is gone, so requests fail
1433        let err = client.push_metrics().await;
1434        assert!(err.is_err());
1435    }
1436
1437    /// An oversized message never reaches the wire, so it must not cost the
1438    /// connection: re-dialing would pay for a handshake and a full metrics
1439    /// schema resend and then hit the same limit again.
1440    #[test]
1441    fn test_oversized_message_keeps_the_connection() {
1442        let send_error = |source| irpc::Error::Send {
1443            source,
1444            meta: Default::default(),
1445        };
1446
1447        assert!(!is_connection_lost(&send_error(
1448            irpc::channel::SendError::MaxMessageSizeExceeded {
1449                meta: Default::default(),
1450            }
1451        )));
1452        assert!(is_connection_lost(&send_error(
1453            irpc::channel::SendError::ReceiverClosed {
1454                meta: Default::default(),
1455            }
1456        )));
1457    }
1458
1459    /// Assert that shutting down with a request in flight lets that request
1460    /// finish, so the final metrics push still reaches the server.
1461    ///
1462    /// Dropping the request instead would reset its stream, and the server
1463    /// treats that as the connection failing: it stops reading, so the push
1464    /// that shutdown exists for is never seen.
1465    #[tokio::test]
1466    async fn test_shutdown_drains_request_in_flight() {
1467        let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel();
1468        let (router, client_ep, builder) = spawn_test_server(
1469            9,
1470            // long enough for shutdown to land while a ping is in flight
1471            TestServer::new(seen_tx).ping_delay(Duration::from_millis(300)),
1472        )
1473        .await;
1474
1475        let client = builder
1476            // long enough that only the startup tick fires on its own
1477            .metrics_interval(Duration::from_secs(3600))
1478            .build()
1479            .await
1480            .unwrap();
1481
1482        // the startup push is what establishes the connection
1483        next_metrics(&mut seen_rx).await;
1484
1485        // put a ping in flight, then shut down while the server sits on it
1486        let pinging = client.clone();
1487        let ping = task::spawn(async move { pinging.ping().await });
1488        time::sleep(Duration::from_millis(100)).await;
1489        client.shutdown().await;
1490
1491        let recorded = recorded_so_far(&mut seen_rx);
1492        assert!(
1493            recorded
1494                .iter()
1495                .any(|seen| matches!(seen, Seen::PingAnswered)),
1496            "the ping in flight was dropped, so the server saw its stream fail"
1497        );
1498        assert!(
1499            recorded.iter().any(|seen| matches!(seen, Seen::Metrics(_))),
1500            "the final metrics push did not reach the server"
1501        );
1502
1503        let _ = ping.await;
1504        router.shutdown().await.unwrap();
1505        client_ep.close().await;
1506    }
1507
1508    /// Assert that shutdown does not wait for a request in flight.
1509    ///
1510    /// The metrics interval fires as soon as the actor starts, so with an
1511    /// unreachable remote the actor is inside a dial that runs for about a
1512    /// minute. Shutting down has to cancel that dial rather than queue behind
1513    /// it.
1514    #[tokio::test]
1515    async fn test_shutdown_cancels_dial_in_flight() {
1516        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(3);
1517        let shared_secret = SecretKey::from_bytes(&rng.random());
1518        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1519        let api_secret = ApiSecret::new(shared_secret, fake_endpoint_id);
1520
1521        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1522
1523        let client = Client::builder(&endpoint)
1524            .api_secret(api_secret)
1525            .unwrap()
1526            // TEST-NET-1, routable but silently dropped, so the dial hangs
1527            // instead of failing fast the way an address-less remote would.
1528            .remote(
1529                EndpointAddr::new(fake_endpoint_id).with_ip_addr("192.0.2.1:1234".parse().unwrap()),
1530            )
1531            .build()
1532            .await
1533            .unwrap();
1534
1535        // let the startup metrics tick get as far as the dial
1536        time::sleep(Duration::from_millis(200)).await;
1537
1538        time::timeout(Duration::from_secs(5), client.shutdown())
1539            .await
1540            .expect("shutdown blocked on the dial in flight");
1541    }
1542
1543    #[tokio::test]
1544    async fn test_name() {
1545        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(0);
1546        let shared_secret = SecretKey::from_bytes(&rng.random());
1547        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1548        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
1549
1550        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1551
1552        let builder = Client::builder(&endpoint)
1553            .name("my-node 👋")
1554            .unwrap()
1555            .api_secret(api_secret)
1556            .unwrap();
1557
1558        assert_eq!(builder.name, Some("my-node 👋".to_string()));
1559
1560        let Err(err) = Client::builder(&endpoint).name("a") else {
1561            panic!("name should fail for strings under 2 bytes");
1562        };
1563        assert!(matches!(
1564            err.downcast_ref::<BuildError>(),
1565            Some(BuildError::InvalidName(ValidateNameError::TooShort))
1566        ));
1567
1568        let too_long_name = "👋".repeat(129);
1569        let Err(err) = Client::builder(&endpoint).name(&too_long_name) else {
1570            panic!("name should fail for strings over 128 bytes");
1571        };
1572        assert!(matches!(
1573            err.downcast_ref::<BuildError>(),
1574            Some(BuildError::InvalidName(ValidateNameError::TooLong))
1575        ));
1576    }
1577
1578    #[tokio::test]
1579    async fn test_group() {
1580        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(0);
1581        let shared_secret = SecretKey::from_bytes(&rng.random());
1582        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1583        let api_secret = ApiSecret::new(shared_secret.clone(), fake_endpoint_id);
1584
1585        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1586
1587        let builder = Client::builder(&endpoint)
1588            .group("staging")
1589            .unwrap()
1590            .api_secret(api_secret)
1591            .unwrap();
1592
1593        assert_eq!(builder.group, Some("staging".to_string()));
1594
1595        let Err(err) = Client::builder(&endpoint).group("a") else {
1596            panic!("group should fail for strings under 2 bytes");
1597        };
1598        assert!(matches!(
1599            err.downcast_ref::<BuildError>(),
1600            Some(BuildError::InvalidGroup(ValidateNameError::TooShort))
1601        ));
1602
1603        let too_long_group = "👋".repeat(129);
1604        let Err(err) = Client::builder(&endpoint).group(&too_long_group) else {
1605            panic!("group should fail for strings over 128 bytes");
1606        };
1607        assert!(matches!(
1608            err.downcast_ref::<BuildError>(),
1609            Some(BuildError::InvalidGroup(ValidateNameError::TooLong))
1610        ));
1611    }
1612
1613    #[tokio::test]
1614    async fn test_attributes() {
1615        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1616
1617        // empty iterator is accepted (clears attributes server-side)
1618        let builder = Client::builder(&endpoint)
1619            .attributes(std::iter::empty::<(String, String)>())
1620            .unwrap();
1621        assert_eq!(builder.attributes.as_ref().map(|m| m.len()), Some(0));
1622
1623        // array literal of `&str` tuples, for the one-liner ergonomics
1624        let builder = Client::builder(&endpoint)
1625            .attributes([("env", "prod"), ("region", "us-west")])
1626            .unwrap();
1627        let attrs = builder.attributes.as_ref().expect("attributes set");
1628        assert_eq!(attrs.get("env").map(String::as_str), Some("prod"));
1629        assert_eq!(attrs.get("region").map(String::as_str), Some("us-west"));
1630
1631        // HashMap<String, String> also works
1632        let mut map: HashMap<String, String> = HashMap::new();
1633        map.insert("k1".into(), "v1".into());
1634        map.insert("k2".into(), "".into()); // empty value is allowed
1635        let builder = Client::builder(&endpoint).attributes(map).unwrap();
1636        let attrs = builder.attributes.as_ref().expect("attributes set");
1637        assert_eq!(attrs.get("k2").map(String::as_str), Some(""));
1638
1639        // value over 128 bytes errors
1640        let too_long_value = "x".repeat(129);
1641        let Err(err) = Client::builder(&endpoint).attributes([("ok", too_long_value.as_str())])
1642        else {
1643            panic!("attributes should fail for value over 128 bytes");
1644        };
1645        assert!(matches!(
1646            err.downcast_ref::<BuildError>(),
1647            Some(BuildError::InvalidAttributes(
1648                ValidateAttributesError::ValueTooLong
1649            ))
1650        ));
1651
1652        // key under 2 bytes errors
1653        let Err(err) = Client::builder(&endpoint).attributes([("a", "v")]) else {
1654            panic!("attributes should fail for key under 2 bytes");
1655        };
1656        assert!(matches!(
1657            err.downcast_ref::<BuildError>(),
1658            Some(BuildError::InvalidAttributes(
1659                ValidateAttributesError::InvalidKey(ValidateNameError::TooShort)
1660            ))
1661        ));
1662
1663        // more than 128 entries errors
1664        let big: Vec<(String, String)> = (0..(ATTRIBUTES_MAX_COUNT + 1))
1665            .map(|i| (format!("key_{i:04}"), format!("val_{i}")))
1666            .collect();
1667        let Err(err) = Client::builder(&endpoint).attributes(big) else {
1668            panic!("attributes should fail for more than 128 entries");
1669        };
1670        assert!(matches!(
1671            err.downcast_ref::<BuildError>(),
1672            Some(BuildError::InvalidAttributes(
1673                ValidateAttributesError::TooManyEntries
1674            ))
1675        ));
1676    }
1677
1678    /// Build a client with no reachable server, mirroring `test_no_metrics_interval`.
1679    /// The runtime setters validate input locally before any network call, so
1680    /// validation errors surface without a live server.
1681    async fn build_serverless_client(seed: u64) -> Client {
1682        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(seed);
1683        let shared_secret = SecretKey::from_bytes(&rng.random());
1684        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1685        let api_secret = ApiSecret::new(shared_secret, fake_endpoint_id);
1686
1687        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1688
1689        Client::builder(&endpoint)
1690            .disable_metrics_interval()
1691            .api_secret(api_secret)
1692            .unwrap()
1693            .build()
1694            .await
1695            .unwrap()
1696    }
1697
1698    /// Covers the runtime `Client::set_group` path the builder tests miss:
1699    /// validation runs locally and returns `Error::InvalidGroup` without a server.
1700    #[tokio::test]
1701    async fn test_set_group_runtime_validation() {
1702        let client = build_serverless_client(2).await;
1703
1704        let err = client
1705            .set_group("a")
1706            .await
1707            .expect_err("too-short group should fail validation");
1708        assert!(matches!(
1709            err,
1710            Error::InvalidGroup(ValidateNameError::TooShort)
1711        ));
1712
1713        let too_long = "x".repeat(CLIENT_NAME_MAX_LENGTH + 1);
1714        let err = client
1715            .set_group(too_long)
1716            .await
1717            .expect_err("too-long group should fail validation");
1718        assert!(matches!(
1719            err,
1720            Error::InvalidGroup(ValidateNameError::TooLong)
1721        ));
1722    }
1723
1724    /// Covers the runtime `Client::set_attributes` path the builder tests miss:
1725    /// validation runs locally and returns `Error::InvalidAttributes` without a server.
1726    #[tokio::test]
1727    async fn test_set_attributes_runtime_validation() {
1728        let client = build_serverless_client(3).await;
1729
1730        // key under 2 bytes
1731        let err = client
1732            .set_attributes([("a", "v")])
1733            .await
1734            .expect_err("too-short attribute key should fail validation");
1735        assert!(matches!(
1736            err,
1737            Error::InvalidAttributes(ValidateAttributesError::InvalidKey(
1738                ValidateNameError::TooShort
1739            ))
1740        ));
1741
1742        // value over the max length
1743        let too_long_value = "x".repeat(ATTRIBUTE_VALUE_MAX_LENGTH + 1);
1744        let err = client
1745            .set_attributes([("ok", too_long_value.as_str())])
1746            .await
1747            .expect_err("too-long attribute value should fail validation");
1748        assert!(matches!(
1749            err,
1750            Error::InvalidAttributes(ValidateAttributesError::ValueTooLong)
1751        ));
1752
1753        // more entries than allowed
1754        let big: Vec<(String, String)> = (0..(ATTRIBUTES_MAX_COUNT + 1))
1755            .map(|i| (format!("key_{i:04}"), format!("val_{i}")))
1756            .collect();
1757        let err = client
1758            .set_attributes(big)
1759            .await
1760            .expect_err("too many attributes should fail validation");
1761        assert!(matches!(
1762            err,
1763            Error::InvalidAttributes(ValidateAttributesError::TooManyEntries)
1764        ));
1765    }
1766
1767    #[tokio::test]
1768    async fn test_set_attribute_runtime_validation() {
1769        let client = build_serverless_client(7).await;
1770
1771        // A bad single key is rejected before any network call.
1772        let err = client
1773            .set_attribute("a", "v")
1774            .await
1775            .expect_err("too-short attribute key should fail validation");
1776        assert!(matches!(
1777            err,
1778            Error::InvalidAttributes(ValidateAttributesError::InvalidKey(
1779                ValidateNameError::TooShort
1780            ))
1781        ));
1782
1783        // A valid single attribute passes validation, then reaches the dial
1784        // (no server) and surfaces a connect error, proving set_attribute
1785        // is wired through the actor/RPC path.
1786        let err = client
1787            .set_attribute("firmware", "2.1.0")
1788            .await
1789            .expect_err("no server: remote call must fail after validation passes");
1790        assert!(matches!(err, Error::Connect(_)), "got {err:?}");
1791    }
1792
1793    #[tokio::test]
1794    async fn test_set_attribute_merge_over_limit_rejected() {
1795        // A client already holding the maximum number of attributes.
1796        let full: Vec<(String, String)> = (0..ATTRIBUTES_MAX_COUNT)
1797            .map(|i| (format!("key_{i:04}"), "v".to_string()))
1798            .collect();
1799
1800        let mut rng = rand::rngs::ChaCha8Rng::seed_from_u64(9);
1801        let shared_secret = SecretKey::from_bytes(&rng.random());
1802        let fake_endpoint_id = SecretKey::from_bytes(&rng.random()).public();
1803        let api_secret = ApiSecret::new(shared_secret, fake_endpoint_id);
1804        let endpoint = Endpoint::builder(presets::Minimal).bind().await.unwrap();
1805        let client = Client::builder(&endpoint)
1806            .disable_metrics_interval()
1807            .attributes(full)
1808            .unwrap()
1809            .api_secret(api_secret)
1810            .unwrap()
1811            .build()
1812            .await
1813            .unwrap();
1814
1815        // Merging one more (individually valid) entry pushes the set over the
1816        // limit. The single-entry check would miss this; the merged-set check in
1817        // the actor catches it locally, before any network call.
1818        let err = client
1819            .set_attribute("one-too-many", "v")
1820            .await
1821            .expect_err("merging past the attribute limit must fail");
1822        assert!(
1823            matches!(
1824                err,
1825                Error::InvalidAttributes(ValidateAttributesError::TooManyEntries)
1826            ),
1827            "expected TooManyEntries, got {err:?}"
1828        );
1829    }
1830
1831    /// Boundary "accepted" case for the runtime setter. Without a live server we
1832    /// cannot assert success; instead we assert the input passes local validation
1833    /// and the call proceeds to the (failing) dial, surfacing `Error::Connect`
1834    /// rather than an `Error::InvalidAttributes` validation error.
1835    #[tokio::test]
1836    async fn test_set_attributes_runtime_boundary_accepted() {
1837        let client = build_serverless_client(4).await;
1838
1839        // value of exactly the max length is accepted by validation
1840        let max_value = "x".repeat(ATTRIBUTE_VALUE_MAX_LENGTH);
1841        let err = client
1842            .set_attributes([("ok".to_string(), max_value)])
1843            .await
1844            .expect_err("no server: remote call must fail after validation passes");
1845        assert!(
1846            matches!(err, Error::Connect(_)),
1847            "expected a connect error (validation accepted), got {err:?}"
1848        );
1849
1850        // Exactly ATTRIBUTES_MAX_COUNT entries is accepted by validation.
1851        let max_entries: Vec<(String, String)> = (0..ATTRIBUTES_MAX_COUNT)
1852            .map(|i| (format!("key_{i:04}"), format!("val_{i}")))
1853            .collect();
1854        let err = client
1855            .set_attributes(max_entries)
1856            .await
1857            .expect_err("no server: remote call must fail after validation passes");
1858        assert!(
1859            matches!(err, Error::Connect(_)),
1860            "expected a connect error (validation accepted), got {err:?}"
1861        );
1862    }
1863}