umshctl/
mesh.rs

1//! The host stack this tool becomes to reach a device over the mesh.
2//!
3//! Every other part of this tool speaks ULCP down the wire to the radio
4//! it is attached to. Reaching a device across the room means borrowing
5//! that radio and becoming a node: running a host MAC over the
6//! attachment, and speaking the same ULCP grammar to the far end,
7//! carried by the Node Management binding
8//! (`docs/protocol/src/app-node-management.md`).
9//!
10//! Two identities are in play and it is worth keeping them apart. The
11//! attached radio has its own device identity, which `identity` shows.
12//! This tool has a separate, persistent administrator identity —
13//! `admin-key` prints it — and a device is managed from here only once
14//! that key is listed in its `PROP_DEV_ADMINS`, which `dev-admin add`
15//! does over a bench link.
16
17use std::path::Path;
18use std::time::Duration;
19
20use anyhow::{Context, Result, anyhow, bail};
21use rand::{Rng as _, rng};
22use tokio::time::Instant;
23
24use umsh::core::PublicKey;
25use umsh::crypto::{
26    CryptoEngine, NodeIdentity,
27    software::{SoftwareAes, SoftwareIdentity, SoftwareSha256},
28};
29use umsh::hal::Radio;
30use umsh::mac::{Mac, MacHandle, OperatingPolicy, RepeaterConfig};
31use umsh::node::{Host, LocalNode};
32use umsh::node_mgmt::admin::{Failure, Outcome};
33use umsh::node_mgmt::{NodeManager, Progress};
34use umsh::tokio_support::{StdClock, TokioFileCounterStore, TokioFileKeyValueStore, TokioPlatform};
35use umsh::ulcp::{UlcpDevice, UlcpDeviceConfig, UlcpError};
36use umsh::ulcp_mesh::{
37    DeliveredOutcome, MeshEndpoint, MeshFault, MeshFrameLink, MeshRequest, mesh_link,
38};
39use umsh::ulcp_wire::ids::prop;
40use umsh_sync::AsyncRefCell;
41
42use crate::App;
43use crate::connection::{self, Session, SessionLink};
44use crate::output::{field, note};
45
46// ─── The host stack this tool becomes ────────────────────────────────────────
47
48/// One identity — the administrator's. Channels and queues are sized for
49/// a tool that talks to one device at a time and holds one exchange open
50/// while it does.
51///
52/// Peers are sized for `discover` instead, which is the one command that
53/// meets a crowd: every stranger that answers a solicitation is
54/// auto-registered from the full key it sends, and the registry evicts
55/// the least recently used once it is full. Four slots would have made a
56/// discovery of any size report the last four nodes to speak. This is a
57/// host tool with a host's memory.
58const IDENTITIES: usize = 1;
59const PEERS: usize = 32;
60const CHANNELS: usize = 1;
61const ACKS: usize = 8;
62const TX: usize = 8;
63const FRAME: usize = 256;
64const DUP: usize = 32;
65
66pub type CtlPlatform<R> = TokioPlatform<R, TokioFileCounterStore, TokioFileKeyValueStore>;
67pub type CtlMac<R> = Mac<CtlPlatform<R>, IDENTITIES, PEERS, CHANNELS, ACKS, TX, FRAME, DUP>;
68pub type CtlHandle<'a, R> =
69    MacHandle<'a, CtlPlatform<R>, IDENTITIES, PEERS, CHANNELS, ACKS, TX, FRAME, DUP>;
70pub type CtlHost<'a, R> = Host<CtlHandle<'a, R>>;
71
72/// How long the whole operation may take before the tool gives up.
73///
74/// The exchange engine has its own attempt budget; this bounds the
75/// continuation loop of a large read, where each fragment restarts that
76/// budget.
77pub const OPERATION_TIMEOUT: Duration = Duration::from_secs(180);
78
79// ─── The administrator identity ──────────────────────────────────────────────
80
81/// Load the administrator identity, generating one the first time.
82///
83/// The seed is a plain 32-byte file, written where the rest of this
84/// tool's state lives.
85pub fn admin_identity() -> Result<SoftwareIdentity> {
86    let path = connection::admin_identity_path()
87        .ok_or_else(|| anyhow!("no HOME directory to keep an administrator identity in"))?;
88    load_or_create_identity(&path)
89}
90
91fn load_or_create_identity(path: &Path) -> Result<SoftwareIdentity> {
92    match std::fs::read(path) {
93        Ok(bytes) => {
94            let secret: [u8; 32] = bytes.try_into().map_err(|_| {
95                anyhow!(
96                    "{} is not a 32-byte identity seed; move it aside to start over",
97                    path.display()
98                )
99            })?;
100            Ok(SoftwareIdentity::from_secret_bytes(&secret))
101        }
102        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
103            if let Some(parent) = path.parent() {
104                std::fs::create_dir_all(parent)
105                    .with_context(|| format!("creating {}", parent.display()))?;
106            }
107            let mut secret = [0u8; 32];
108            rng().fill_bytes(&mut secret);
109            std::fs::write(path, secret).with_context(|| format!("writing {}", path.display()))?;
110            note("generated a new administrator identity");
111            Ok(SoftwareIdentity::from_secret_bytes(&secret))
112        }
113        Err(error) => Err(error).with_context(|| format!("reading {}", path.display())),
114    }
115}
116
117/// `admin-key`: print the public key a device must list to be managed
118/// from here.
119pub fn show_admin_key() -> Result<()> {
120    let identity = admin_identity()?;
121    field("administrator", identity.public_key().to_string());
122    if let Some(path) = connection::admin_identity_path() {
123        field("identity", path.display());
124    }
125    note("a device lists this key under `dev-admin add` before it will answer");
126    Ok(())
127}
128
129// ─── Bring-up ────────────────────────────────────────────────────────────────
130
131/// The device's live PHY, as a radio configuration.
132///
133/// The host MAC paces itself on modeled airtime, and the attach this
134/// tool uses deliberately leaves the radio's configuration alone, so the
135/// defaults it carries may describe some other radio entirely. Read the
136/// real ones before borrowing the link.
137///
138/// A radio whose PHY is switched off refuses every transmission, and it
139/// does so far downstream — as an `INVALID_STATE` on the first frame the
140/// MAC tries to send, long after the radio has been borrowed. Reading
141/// the flag here turns that into an answer the caller can act on, while
142/// it still has its attachment.
143pub async fn adopt_phy(device: &mut UlcpDevice<SessionLink>) -> Result<UlcpDeviceConfig> {
144    let keys = [
145        prop::PHY_FREQ,
146        prop::PHY_LORA_BW,
147        prop::PHY_LORA_SF,
148        prop::PHY_LORA_CR,
149        prop::PHY_TX_POWER,
150        prop::PHY_ENABLED,
151    ];
152    let answers = device.get_props(&keys).await?;
153    let mut config = connection::attach_config();
154    for (requested, answer) in keys.iter().zip(&answers) {
155        let Ok((_, value)) = answer else { continue };
156        match *requested {
157            prop::PHY_ENABLED => {
158                if value.first() == Some(&0) {
159                    bail!("the radio's PHY is switched off; turn it on with `set phy-enabled on`");
160                }
161            }
162            prop::PHY_FREQ => {
163                if let Ok(bytes) = <[u8; 4]>::try_from(&value[..]) {
164                    config.freq_khz = u32::from_le_bytes(bytes);
165                }
166            }
167            prop::PHY_LORA_BW => {
168                if let Ok(bytes) = <[u8; 4]>::try_from(&value[..]) {
169                    config.bandwidth_hz = u32::from_le_bytes(bytes);
170                }
171            }
172            prop::PHY_LORA_SF => {
173                if let Some(&sf) = value.first() {
174                    config.spreading_factor = sf;
175                }
176            }
177            prop::PHY_LORA_CR => {
178                if let Some(&cr) = value.first() {
179                    config.coding_rate_denom = cr;
180                }
181            }
182            prop::PHY_TX_POWER => {
183                if let Some(&power) = value.first() {
184                    config.tx_power_dbm = power as i8;
185                }
186            }
187            _ => {}
188        }
189    }
190    Ok(config)
191}
192
193/// This host owns the MAC and does its own filtering, so the radio's
194/// provisioned receive filters must not gate delivery. The mode is
195/// session-scoped and touches no provisioning.
196pub async fn prepare_radio(device: &mut UlcpDevice<SessionLink>) -> Result<()> {
197    match device.set_prop(prop::MAC_PROMISCUOUS, &[1]).await {
198        Ok(_) => Ok(()),
199        Err(UlcpError::Status(status)) => {
200            note(format!(
201                "radio refused promiscuous mode ({status:?}); reception follows its own filtering"
202            ));
203            Ok(())
204        }
205        Err(error) => Err(error.into()),
206    }
207}
208
209/// Open the frame-counter store the administrator identity persists to.
210///
211/// Separate from [`build_mac`] so a failure here happens before the
212/// radio is consumed, leaving the caller its attachment to hand back.
213pub fn counter_store() -> Result<TokioFileCounterStore> {
214    let path = connection::admin_counter_path()
215        .ok_or_else(|| anyhow!("no HOME directory to keep frame counters in"))?;
216    TokioFileCounterStore::new(path)
217        .map_err(|error| anyhow!("opening the counter store: {error:?}"))
218}
219
220/// Something this tool runs as a node on its own radio.
221///
222/// `manage`, the messaging commands, and `discover` all want the same
223/// preamble — read the device's PHY, take the attachment over, turn it
224/// into a MAC — and the same guarantee afterwards, that the attachment
225/// comes back whether the errand worked or not. That is
226/// [`borrowing_the_radio`]; this is the part that differs. It is a trait
227/// rather than a closure because it has to be generic over whatever
228/// radio the session happens to be holding.
229pub trait RadioErrand {
230    async fn run<R: Radio>(
231        self,
232        mac: &AsyncRefCell<CtlMac<R>>,
233        identity: SoftwareIdentity,
234    ) -> Result<()>
235    where
236        R::Error: core::fmt::Debug;
237}
238
239/// Take the attachment over as this tool's radio, run `errand` on it,
240/// and hand the attachment back.
241///
242/// This tool has one radio. A command that kept it on failure would
243/// leave the session holding nothing, so the give-back is unconditional
244/// — every early return here happens before the link is consumed.
245pub async fn borrowing_the_radio<E: RadioErrand>(app: &mut App, errand: E) -> Result<()> {
246    let identity = admin_identity()?;
247    // Read the device's own PHY before taking the link over, so the host
248    // MAC paces itself the way the radio is actually configured.
249    let config = adopt_phy(app.device()?).await?;
250
251    let Some(session) = app.session.take() else {
252        bail!("not attached — try `ble-scan` or `connect`");
253    };
254    let Session {
255        device,
256        target,
257        label,
258        tap,
259    } = session;
260    // Re-attaching is the only way to give the handle a configuration; it
261    // costs a handful of property reads and no reconnect. The link is
262    // consumed, so a failure here really does end the attachment.
263    let mut device = UlcpDevice::attach_administrative(device.into_link(), config)
264        .await
265        .context("re-attaching the radio with its own PHY")?;
266    if app.trace {
267        connection::install_trace(&mut device);
268    }
269    prepare_radio(&mut device).await?;
270
271    let (device, result) = match counter_store() {
272        Ok(store) => {
273            let mac = build_mac(device, store);
274            let result = errand.run(&mac, identity).await;
275            (mac.into_inner().into_radio(), result)
276        }
277        Err(error) => (device, Err(error)),
278    };
279    app.session = Some(Session {
280        device,
281        target,
282        label,
283        tap,
284    });
285    result
286}
287
288/// Take the radio over as this tool's MAC.
289pub fn build_mac<R: Radio>(radio: R, store: TokioFileCounterStore) -> AsyncRefCell<CtlMac<R>> {
290    AsyncRefCell::new(Mac::new(
291        radio,
292        CryptoEngine::new(SoftwareAes, SoftwareSha256),
293        StdClock::new(),
294        rng(),
295        store,
296        RepeaterConfig::default(),
297        OperatingPolicy::default(),
298    ))
299}
300
301/// The tool as a node: a host MAC over the borrowed radio, one local
302/// node standing for the administrator identity, and the pump that keeps
303/// both moving.
304pub struct NodeStack<'a, R: Radio> {
305    pub host: CtlHost<'a, R>,
306    pub node: LocalNode<CtlHandle<'a, R>>,
307    pub handle: CtlHandle<'a, R>,
308    started: Instant,
309}
310
311impl<'a, R: Radio> NodeStack<'a, R>
312where
313    R::Error: core::fmt::Debug,
314{
315    /// Register `identity` on the borrowed MAC and stand a node up on it,
316    /// returning the stack and the administrator's public key.
317    pub async fn build(
318        mac: &'a AsyncRefCell<CtlMac<R>>,
319        identity: SoftwareIdentity,
320    ) -> Result<(Self, PublicKey)> {
321        let handle = MacHandle::new(mac);
322        let local_key = *identity.public_key();
323        let identity_id = handle
324            .add_identity(identity)
325            .await
326            .map_err(|error| anyhow!("registering the administrator identity: {error:?}"))?;
327        // A frame counter that restarted at zero would be rejected as a
328        // replay by every device that has heard this identity before.
329        handle
330            .load_persisted_counter(identity_id)
331            .await
332            .map_err(|error| anyhow!("loading persisted frame counters: {error:?}"))?;
333
334        let mut host: CtlHost<'a, R> = Host::new(handle);
335        let node = host.add_node(identity_id);
336        Ok((
337            Self {
338                host,
339                node,
340                handle,
341                started: Instant::now(),
342            },
343            local_key,
344        ))
345    }
346
347    /// When this stack came up. The exchange engine's deadlines are
348    /// expressed against [`Self::now_ms`], which counts from here.
349    pub fn started(&self) -> Instant {
350        self.started
351    }
352
353    pub fn now_ms(&self) -> u64 {
354        self.started.elapsed().as_millis() as u64
355    }
356
357    /// Drive the MAC until it has nothing to do or `deadline` arrives.
358    ///
359    /// A quiet radio produces no MAC wake, so the timeouts that retire an
360    /// unanswered acknowledgment need their own nudge afterwards.
361    pub async fn pump_until(&mut self, deadline: Instant) -> Result<()> {
362        tokio::select! {
363            result = self.host.pump_once() => {
364                result.map_err(|error| anyhow!("the radio stopped answering: {error:?}"))?;
365            }
366            _ = tokio::time::sleep_until(deadline) => {}
367        }
368        self.host.service_protocol_timeouts().await;
369        let _ = self.handle.service_counter_persistence().await;
370        Ok(())
371    }
372
373    /// Carry one exchange to its end, pumping the host in between, giving
374    /// up at `give_up`.
375    pub async fn exchange(
376        &mut self,
377        manager: &mut NodeManager<CtlHandle<'a, R>>,
378        request: &[u8],
379        give_up: Instant,
380    ) -> Result<Outcome> {
381        manager
382            .begin(request, self.now_ms())
383            .map_err(|error| anyhow!("{error:?}"))?;
384        loop {
385            if Instant::now() > give_up {
386                bail!("gave up after {} s", OPERATION_TIMEOUT.as_secs());
387            }
388            let progress = manager
389                .service(self.now_ms())
390                .await
391                .map_err(|error| anyhow!("sending to the device: {error:?}"))?;
392            let deadline_ms = match progress {
393                Progress::Done(outcome) => return Ok(outcome),
394                Progress::Waiting { deadline_ms } => deadline_ms,
395            };
396            let wait = Duration::from_millis(deadline_ms.saturating_sub(self.now_ms()));
397            self.pump_until(Instant::now() + wait).await?;
398        }
399    }
400}
401
402/// What a failed exchange means to somebody holding the tool.
403pub fn describe(failure: Failure) -> anyhow::Error {
404    match failure {
405        Failure::TimedOut => anyhow!(
406            "no answer — the device may be out of range, or this tool may not be one of its \
407             administrators (`admin-key` prints the key it would have to list)"
408        ),
409        Failure::CursorInvalid => {
410            anyhow!("the device's state changed mid-read; run the command again")
411        }
412        Failure::TooLarge => anyhow!("the answer is larger than this tool reassembles"),
413        Failure::Malformed => anyhow!("the device's answer could not be read"),
414        Failure::UnknownCriticalOption(number) => {
415            anyhow!("the device's answer carries option {number}, which this tool does not know")
416        }
417    }
418}
419
420// ─── A persistent session over the mesh ──────────────────────────────────────
421
422/// What a mesh session borrowed, and how to give it back.
423///
424/// The driver task owns the radio for as long as the session lasts; its
425/// join handle is how the radio comes home, and the rest is what the
426/// local session was called before it was lent out.
427pub struct MeshHome {
428    pub driver: tokio::task::JoinHandle<UlcpDevice<SessionLink>>,
429    pub local_target: connection::Target,
430    pub local_label: String,
431    pub tap: connection::FrameTap,
432}
433
434/// Whether opening a session should say hello first.
435#[derive(Clone, Copy, PartialEq, Eq)]
436pub enum Greeting {
437    /// Ask the device its name, so the prompt is something a person
438    /// recognizes and an unreachable node is reported now rather than by
439    /// whatever they type first. One exchange.
440    Named,
441    /// Say nothing. The key is the label, and the command the caller
442    /// came for is the first thing on the air.
443    Silent,
444}
445
446/// Borrow the attached radio and open a ULCP session to `target` over the
447/// mesh.
448///
449/// On success `app` is attached to the remote device and holds the
450/// [`MeshHome`] that ends the session. On failure the local attachment is
451/// restored, because a tool that loses your radio for mistyping a key is
452/// not one you would use twice.
453pub async fn open_remote(app: &mut App, target: PublicKey, greeting: Greeting) -> Result<()> {
454    if app.mesh.is_some() {
455        bail!("already on a mesh session — `disconnect` returns to the radio");
456    }
457    let identity = admin_identity()?;
458
459    // Read the device's own PHY before taking the link over, and open the
460    // counter store while there is still an attachment to hand back.
461    let phy = adopt_phy(app.device()?).await?;
462    let store = counter_store()?;
463
464    let Some(session) = app.session.take() else {
465        bail!("not attached — try `ble-scan` or `connect`");
466    };
467    let connection::Session {
468        device,
469        target: local_target,
470        label: local_label,
471        tap,
472    } = session;
473
474    // Re-attaching is the only way to give the handle the configuration
475    // the host MAC paces itself on. The link is consumed, so a failure
476    // here really does end the attachment.
477    let mut radio = UlcpDevice::attach_administrative(device.into_link(), phy.clone())
478        .await
479        .context("re-attaching the radio with its own PHY")?;
480    if app.trace {
481        connection::install_trace(&mut radio);
482    }
483    prepare_radio(&mut radio).await?;
484
485    let (link, endpoint) = mesh_link();
486    let driver = tokio::task::spawn_local(drive(radio, store, identity, target, endpoint));
487
488    // From here the radio belongs to the driver, and the only way back to
489    // it is through the join handle.
490    let home = MeshHome {
491        driver,
492        local_target,
493        local_label,
494        tap,
495    };
496    match open_session(link, phy, target, greeting).await {
497        Ok(session) => {
498            app.session = Some(session);
499            app.mesh = Some(home);
500            Ok(())
501        }
502        Err(error) => {
503            // The link is already dropped, so the driver is winding down;
504            // wait for the radio and put the local session back.
505            restore_local(app, home).await;
506            Err(error)
507        }
508    }
509}
510
511/// Open the device handle at the far end of `link`.
512///
513/// Opening costs nothing on the air — the binding needs no session and
514/// the device is told nothing — so the only exchange here is the name,
515/// and only when one was asked for.
516async fn open_session(
517    link: MeshFrameLink,
518    phy: UlcpDeviceConfig,
519    target: PublicKey,
520    greeting: Greeting,
521) -> Result<connection::Session> {
522    let tap = connection::new_tap();
523    let session_link = SessionLink::new(connection::AnyLink::Mesh(link), tap.clone());
524    let mut device = UlcpDevice::open_remote(session_link, connection::mesh_attach_config(phy));
525    // A device that will not say its name is still perfectly usable; the
526    // key it answers to will do as a label.
527    let label = match greeting {
528        Greeting::Silent => target.to_string(),
529        Greeting::Named => {
530            note("reaching the device over the mesh — an exchange can take a while");
531            match device.device_name().await {
532                Ok(name) if !name.is_empty() => name,
533                Ok(_) => target.to_string(),
534                Err(error) => {
535                    return Err(
536                        anyhow::Error::new(error).context("reaching the device over the mesh")
537                    );
538                }
539            }
540        }
541    };
542    Ok(connection::Session {
543        device,
544        target: connection::Target::Mesh { key: target.0 },
545        label,
546        tap,
547    })
548}
549
550/// End a mesh session and put the local attachment back.
551///
552/// The session's device handle must already be dropped: that is what
553/// closes the link and tells the driver to wind down.
554pub async fn restore_local(app: &mut App, home: MeshHome) {
555    let MeshHome {
556        driver,
557        local_target,
558        local_label,
559        tap,
560    } = home;
561    match driver.await {
562        Ok(radio) => {
563            app.session = Some(connection::Session {
564                device: radio,
565                target: local_target,
566                label: local_label,
567                tap,
568            });
569        }
570        Err(error) => {
571            // The driver panicked or was cancelled, and the radio went
572            // with it. Say so rather than leaving a session that is not
573            // attached to anything.
574            note(format!(
575                "the mesh session ended badly ({error}); the radio was not recovered"
576            ));
577        }
578    }
579}
580
581/// The driver task: owns the borrowed radio for the life of the session,
582/// carries every request the link hands it, and gives the radio back.
583async fn drive(
584    radio: UlcpDevice<SessionLink>,
585    store: TokioFileCounterStore,
586    identity: SoftwareIdentity,
587    target: PublicKey,
588    endpoint: MeshEndpoint,
589) -> UlcpDevice<SessionLink> {
590    let mac = build_mac(radio, store);
591    serve(&mac, identity, target, endpoint).await;
592    mac.into_inner().into_radio()
593}
594
595/// One step of the driver loop: what the select settled on.
596enum Step {
597    /// A request to carry.
598    Carry(MeshRequest),
599    /// The MAC made progress on its own.
600    Pumped,
601    /// The link is gone; the session is over.
602    Closed,
603}
604
605async fn serve<R: Radio>(
606    mac: &AsyncRefCell<CtlMac<R>>,
607    identity: SoftwareIdentity,
608    target: PublicKey,
609    mut endpoint: MeshEndpoint,
610) where
611    R::Error: core::fmt::Debug,
612{
613    let (mut stack, _local_key) = match NodeStack::build(mac, identity).await {
614        Ok(built) => built,
615        Err(error) => return endpoint.fail(MeshFault::Radio(format!("{error:#}"))),
616    };
617    let peer = match stack.node.peer(target).await {
618        Ok(peer) => peer,
619        Err(error) => {
620            return endpoint.fail(MeshFault::Radio(format!(
621                "registering the device as a peer: {error:?}"
622            )));
623        }
624    };
625    // Whatever an earlier invocation learned about reaching this device,
626    // put back before the first frame goes out. A wrong guess costs one
627    // exchange and the MAC's own retry finds the path again; not
628    // guessing costs a flood every time the tool is run.
629    let mut routes = crate::routes::RouteCache::load();
630    if let Some(record) = routes.get(&target) {
631        peer.restore_route(record.route.clone()).await;
632    }
633
634    // The first token has only to be unpredictable; the manager keeps
635    // every one after it distinct from all of its own.
636    let mut seed = [0u8; 2];
637    rng().fill_bytes(&mut seed);
638    let mut manager = NodeManager::new(peer, u16::from_be_bytes(seed));
639
640    let mut fatal = None;
641    loop {
642        // The arms only settle what happened. Everything that needs the
643        // stack runs below, once the futures borrowing it are gone.
644        let step = tokio::select! {
645            request = endpoint.next() => match request {
646                Some(request) => Step::Carry(request),
647                None => Step::Closed,
648            },
649            result = stack.host.pump_once() => match result {
650                Ok(()) => Step::Pumped,
651                Err(error) => {
652                    fatal = Some(MeshFault::Radio(format!(
653                        "the radio stopped answering: {error:?}"
654                    )));
655                    break;
656                }
657            },
658        };
659        match step {
660            Step::Closed => break,
661            Step::Pumped => {
662                stack.host.service_protocol_timeouts().await;
663                let _ = stack.handle.service_counter_persistence().await;
664            }
665            Step::Carry(request) => {
666                let give_up = Instant::now() + OPERATION_TIMEOUT;
667                match stack.exchange(&mut manager, request.frame(), give_up).await {
668                    Ok(Outcome::Replied { .. }) => {
669                        let reply = manager.reply().to_vec();
670                        endpoint.deliver(&request, DeliveredOutcome::Replied(&reply));
671                    }
672                    Ok(Outcome::NoResponse) => {
673                        endpoint.deliver(&request, DeliveredOutcome::NoResponse)
674                    }
675                    Ok(Outcome::Failed(failure)) => {
676                        endpoint.deliver(&request, DeliveredOutcome::Failed(failure))
677                    }
678                    // This command could not be carried — it ran out of
679                    // patience, or the engine would not take it. The
680                    // session survives: a radio that has actually died
681                    // fails the pump on the very next turn of this loop,
682                    // and that is what ends things.
683                    Err(error) => endpoint.refuse(format!("{error:#}")),
684                }
685                // An exchange is where a route is learned, so this is
686                // where there is something new to remember. Writing per
687                // exchange rather than at the end also means the file is
688                // current while a shell session is still open, which is
689                // what lets `routes` report on one.
690                routes.harvest(&stack.handle).await;
691                if let Err(error) = routes.store() {
692                    crate::output::warn(format!("could not save learned routes: {error:#}"));
693                }
694            }
695        }
696    }
697    let _ = stack.handle.service_counter_persistence().await;
698    routes.harvest(&stack.handle).await;
699    if let Err(error) = routes.store() {
700        crate::output::warn(format!("could not save learned routes: {error:#}"));
701    }
702    if let Some(fault) = fatal {
703        endpoint.fail(fault);
704    }
705}