Skip to main content

openlogi_device/
inventory.rs

1//! Enumerate connected HID++ receivers and their paired devices.
2
3use std::{
4    collections::{HashMap, HashSet},
5    hash::Hash,
6    sync::Arc,
7    time::{Duration, Instant},
8};
9
10use futures_concurrency::future::Join as _;
11use hidpp::channel::HidppChannel;
12use openlogi_core::device::DeviceInventory;
13use thiserror::Error;
14use tracing::{debug, warn};
15
16use crate::ChannelRegistry;
17use crate::backend::{BackendError, HidBackend, NodeId, NodeInfo};
18use crate::channel::route::{DeviceRoute, find_receiver};
19use ledger::{NodeLedger, SettledNode};
20
21mod cache;
22mod channel_cache;
23pub mod events;
24mod features;
25pub mod hotplug;
26mod ledger;
27mod mappings;
28pub mod persist;
29mod probe;
30pub mod standalone;
31
32use cache::{CACHE_MISS_GRACE, CacheKey, CacheOutcome, Cached};
33use channel_cache::ChannelCache;
34use events::{ChannelEventSubscriptions, EventNotifier, EventSubscriptionHandle};
35use persist::{ProbeCacheSnapshot, ProbeCacheStore};
36use probe::{NodeProbe, PassContext, ProbeTimeouts, ProbeVerdict, probe_one};
37#[cfg(test)]
38use probe::{UNIFYING_CACHED_SLOT_PROBE_TIMEOUT, UNIFYING_SLOT_PROBE_TIMEOUT};
39
40/// Errors raised while enumerating HID++ devices.
41#[derive(Debug, Error)]
42pub enum InventoryError {
43    /// Underlying HID backend error.
44    #[error("HID transport error")]
45    Hid(#[from] BackendError),
46    /// More than one indistinguishable standalone raw-HID node was found.
47    #[error("multiple indistinguishable standalone raw HID devices found")]
48    AmbiguousRawDevice,
49}
50
51/// How long a successful feature-table probe is reused.
52///
53/// [`probe_cache_is_stale`] stays false through this age and becomes true only
54/// after it. A scan at exactly this age still hits the cache.
55pub const PROBE_CACHE_FRESHNESS: Duration = cache::REFRESH_INTERVAL;
56
57/// Reports whether a probe stamped at `probed_at` must be walked again at `now`.
58#[must_use]
59pub fn probe_cache_is_stale(probed_at: Instant, now: Instant) -> bool {
60    now.saturating_duration_since(probed_at) > PROBE_CACHE_FRESHNESS
61}
62
63/// Stateful device enumerator: owns persistent channels and the per-device
64/// probe cache so the event-first watcher reuses immutable data across
65/// reconciliations. One-shot callers use the [`enumerate`] free function, which
66/// runs against a fresh (empty) cache.
67pub struct Enumerator {
68    /// The HID stack this enumerator walks. `openlogi-hid` supplies this
69    /// host's; tests and other hosts supply their own.
70    backend: Arc<dyn HidBackend>,
71    cache: HashMap<CacheKey, Cached>,
72    /// Consecutive passes each cached device has been missing, for grace-period
73    /// eviction.
74    misses: HashMap<CacheKey, u8>,
75    /// The cache entries each node has contributed and still holds: what a
76    /// deferred tick holds out of miss aging, since a probe that never ran
77    /// has seen nothing and missed nothing (see [`Self::evict_unseen`]).
78    node_cache_keys: HashMap<NodeId, HashSet<CacheKey>>,
79    /// Open HID++ channels reused across reconciliations, keyed by OS node id.
80    /// Opening (and tearing down) a device every pass is the churn issue #99 is about —
81    /// each open also leaks an `io_service_t` in async-hid's macOS backend — so a
82    /// steadily-connected node is opened once here and reused until it
83    /// disconnects.
84    channels: ChannelCache<NodeId, CachedChannel>,
85    /// Per-node last-good inventory + consecutive-failure counts: replays a
86    /// node's snapshot through transient probe failures and decides when its
87    /// cached channel must be dropped and reopened (see [`crate::inventory::ledger`]).
88    ledger: NodeLedger<NodeId>,
89    /// Optional publication sink used by the persistent Agent watcher. One-shot
90    /// callers keep this `None` and retain the route-opening library behavior.
91    registry: Option<ChannelRegistry>,
92    /// Where the immutable probe cache is kept across restarts, `None` for a
93    /// memory-only enumerator (one-shot CLI calls, tests).
94    store: Option<Arc<dyn ProbeCacheStore>>,
95    /// Whether the persistable cache content changed since the last save —
96    /// fresh full probes and evictions, not per-pass battery refreshes.
97    cache_dirty: bool,
98    /// Whether the most recent pass failed to open at least one HID++ node.
99    open_failures_last_tick: bool,
100    /// Whether the most recent pass needs a bounded fast follow-up to advance
101    /// ledger/channel/cache grace or retry a failed open.
102    retry_needed_last_tick: bool,
103    /// Coalesced lifecycle-event sink installed on newly opened channels.
104    event_notifier: Option<EventNotifier>,
105    /// The timeouts every pass's probes run under.
106    timeouts: ProbeTimeouts,
107}
108
109/// An open channel to a receiver / direct-device HID node, held across
110/// `enumerate` ticks. Evicting it (on disconnect, or when the `Enumerator`
111/// drops) closes the device and joins the channel's read thread via
112/// [`HidppChannel`]'s `Drop`.
113struct CachedChannel {
114    info: NodeInfo,
115    channel: Arc<HidppChannel>,
116    events: Option<ChannelEventSubscriptions>,
117}
118
119type ActiveNode = (NodeInfo, Arc<HidppChannel>, Option<EventSubscriptionHandle>);
120
121struct PreparedNodes {
122    active: Vec<ActiveNode>,
123    open_failures: Vec<NodeId>,
124    retiring: Vec<NodeId>,
125}
126
127fn routes_for_inventories(inventories: &[DeviceInventory]) -> Vec<DeviceRoute> {
128    inventories
129        .iter()
130        .flat_map(|inventory| {
131            inventory
132                .paired
133                .iter()
134                .filter_map(|paired| DeviceRoute::for_slot(inventory, paired.slot))
135        })
136        .collect()
137}
138
139/// Fold one probe into the ledger. A deferred probe never touched the node,
140/// so it is replayed without a failure on the ledger's count; its verdict
141/// still fails `all_healthy`, which brings the one-shot retry round again.
142fn settle_probe<Node: Eq + Hash + Clone>(
143    ledger: &mut NodeLedger<Node>,
144    node: &Node,
145    verdict: ProbeVerdict,
146    inventory: Option<DeviceInventory>,
147) -> SettledNode {
148    match verdict {
149        ProbeVerdict::Deferred => ledger.defer(node),
150        ProbeVerdict::AliveButIncomplete => ledger.settle_arrival_replay_failure(node),
151        verdict => ledger.settle(node, verdict.is_healthy(), inventory),
152    }
153}
154
155fn settle_unhealthy_node<Node: Eq + Hash + Clone>(
156    ledger: &mut NodeLedger<Node>,
157    node: &Node,
158    all_complete: &mut bool,
159    all_healthy: &mut bool,
160) -> Option<DeviceInventory> {
161    *all_complete = false;
162    *all_healthy = false;
163    ledger.settle(node, false, None).inventory
164}
165
166/// Enumerate all Logitech HID++ receivers visible to the current process and
167/// the devices paired to each.
168///
169/// Combines two data sources per receiver:
170///
171/// - `trigger_device_arrival` events — the only path to a device's wireless
172///   PID in hidpp 0.2 (the `wpid` field on `BoltDevicePairingInformation` is
173///   private). Only online, responsive devices show up here.
174/// - `get_device_pairing_information` polled per slot — covers paired-but-
175///   offline devices (sleeping mice, devices on a different host) that the
176///   arrival ping doesn't wake. No wpid for these.
177///
178/// We merge the two so an MX Master that's been asleep still shows up with
179/// its codename and kind even before you click it.
180pub async fn enumerate(
181    backend: Arc<dyn HidBackend>,
182) -> Result<Vec<DeviceInventory>, InventoryError> {
183    // The persistent [`Enumerator`] keeps a per-node ledger across passes, so a
184    // transient probe miss replays the node's last good inventory. A one-shot
185    // caller (CLI `list` / `diag`) builds a fresh `Enumerator` whose ledger is
186    // empty, so a miss has nothing to replay and would surface as an empty or
187    // partial list — the two isolated runs in #218 read 3 devices and 0. Retry a
188    // few times instead, reusing the same enumerator so its ledger accumulates a
189    // snapshot a later attempt can replay and the opened channel stays warm.
190    // #226's 5 s request timeout inside `HidppChannel::send` makes a dead probe
191    // fail fast, so a short bounded retry is cheap. Some transports can answer
192    // while still yielding a short device set (for example, a Unifying arrival
193    // event landing just after the drain window). When every node answered this
194    // cycle but that healthy pass is still short, two identical inventories mean
195    // the expected stable Unifying offline drain has settled. A failed/timed-out
196    // probe must keep using the full retry budget so the next attempt can reopen
197    // the channel and recover.
198    let mut enumerator = Enumerator::with_backend(backend);
199    let mut scan = OneShotScan::new();
200    loop {
201        let (inventories, all_complete, all_healthy) =
202            enumerator.enumerate_reporting_completeness().await?;
203        let pass = ScanPass {
204            complete: all_complete,
205            healthy: all_healthy,
206        };
207        if scan.is_settled(&inventories, pass) {
208            return Ok(inventories);
209        }
210        debug!(
211            attempt = scan.attempt,
212            complete = pass.complete,
213            healthy = pass.healthy,
214            "one-shot enumerate inventory incomplete or still changing — retrying"
215        );
216        scan.advance(inventories, pass);
217        tokio::time::sleep(ONESHOT_RETRY_DELAY).await;
218    }
219}
220
221/// What one enumerate pass established about its own trustworthiness.
222#[derive(Clone, Copy)]
223struct ScanPass {
224    /// Every expected device is present in the snapshot.
225    complete: bool,
226    /// Every probe answered — the only kind of pass that counts as stability
227    /// evidence.
228    healthy: bool,
229}
230
231/// The one-shot retry loop's memory: the snapshot that may serve as stability
232/// evidence, and how many passes have run. [`Self::is_settled`] is the stop
233/// rule the tests pin.
234struct OneShotScan {
235    /// The previous pass's snapshot, kept only when that pass was healthy —
236    /// the unchanged-inventory stop only ever compares two consecutive
237    /// healthy snapshots. A failed/timed-out probe (a replayed last-good or
238    /// partial live result) is cleared so it can't count as one of the two
239    /// "stable" reads and short-circuit a later healthy-but-short pass.
240    stable_candidate: Option<Vec<DeviceInventory>>,
241    attempt: u8,
242}
243
244impl OneShotScan {
245    fn new() -> Self {
246        Self {
247            stable_candidate: None,
248            attempt: 1,
249        }
250    }
251
252    /// Stop when the snapshot is complete, when a healthy but short pass has
253    /// stabilized (the expected Unifying offline-drain case), or when the
254    /// attempt cap is reached.
255    fn is_settled(&self, current: &[DeviceInventory], pass: ScanPass) -> bool {
256        pass.complete
257            || (pass.healthy
258                && self
259                    .stable_candidate
260                    .as_deref()
261                    .is_some_and(|previous| previous == current))
262            || self.attempt >= ONESHOT_ATTEMPTS
263    }
264
265    /// Fold one finished, unsettled pass in: a healthy snapshot becomes the
266    /// stability candidate, an unhealthy one clears it.
267    fn advance(&mut self, inventories: Vec<DeviceInventory>, pass: ScanPass) {
268        self.stable_candidate = pass.healthy.then_some(inventories);
269        self.attempt += 1;
270    }
271}
272
273/// Attempts a one-shot [`enumerate`] makes before returning whatever it last
274/// read, when an inventory keeps coming back incomplete or changing.
275const ONESHOT_ATTEMPTS: u8 = 4;
276
277/// Delay between one-shot [`enumerate`] retries. A first probe usually wakes an
278/// asleep device, so a short pause lets the next attempt read it cleanly.
279const ONESHOT_RETRY_DELAY: Duration = Duration::from_millis(300);
280
281/// Nodes that remain valid for this pass: everything the OS enumerated plus
282/// cached channels whose open transport still reports a live connection.
283fn retained_nodes<K>(
284    enumerated: &HashSet<K>,
285    cached_channels: impl IntoIterator<Item = (K, bool)>,
286) -> HashSet<K>
287where
288    K: Clone + Eq + Hash,
289{
290    let mut retained = enumerated.clone();
291    retained.extend(
292        cached_channels
293            .into_iter()
294            .filter_map(|(node, connected)| connected.then_some(node)),
295    );
296    retained
297}
298
299/// Add cached channels omitted by this OS enumeration while their open
300/// transport still reports a live connection.
301fn append_live_cached_channels(
302    nodes: &mut HashSet<NodeId>,
303    channels: &ChannelCache<NodeId, CachedChannel>,
304    active: &mut Vec<ActiveNode>,
305) {
306    let retained = retained_nodes(
307        nodes,
308        channels
309            .active_iter()
310            .map(|(node, open)| (node.clone(), open.channel.is_connected())),
311    );
312    for node in retained.difference(nodes) {
313        if let Some(open) = channels.get(node) {
314            debug!(
315                ?node,
316                name = %open.info.name,
317                "OS enumeration omitted a live HID node; probing cached channel"
318            );
319            active.push((
320                open.info.clone(),
321                Arc::clone(&open.channel),
322                open.events.as_ref().map(ChannelEventSubscriptions::handle),
323            ));
324        }
325    }
326    *nodes = retained;
327}
328
329impl Enumerator {
330    /// Whether the most recent [`enumerate`](Self::enumerate) pass failed to
331    /// open at least one HID++ node. `false` before the first pass.
332    ///
333    /// On macOS a run of passes with this set is the observable signature of a
334    /// denied Input Monitoring grant or a stale permission session — paired
335    /// with the grant state it separates "grant it" from "log out", which the
336    /// bare open error cannot (the denial is silent).
337    #[must_use]
338    pub fn open_failures_last_tick(&self) -> bool {
339        self.open_failures_last_tick
340    }
341
342    /// Whether the last pass needs a bounded fast follow-up to advance a
343    /// probe/channel/cache recovery policy. `false` before the first pass.
344    #[must_use]
345    pub fn retry_needed_last_tick(&self) -> bool {
346        self.retry_needed_last_tick
347    }
348
349    /// An enumerator that walks `backend` — this host's HID stack, a scripted
350    /// device tree in tests, or another host's.
351    #[must_use]
352    pub fn with_backend(backend: Arc<dyn HidBackend>) -> Self {
353        Self {
354            backend,
355            cache: HashMap::new(),
356            misses: HashMap::new(),
357            node_cache_keys: HashMap::new(),
358            channels: ChannelCache::default(),
359            ledger: NodeLedger::default(),
360            registry: None,
361            store: None,
362            cache_dirty: false,
363            open_failures_last_tick: false,
364            retry_needed_last_tick: false,
365            event_notifier: None,
366            timeouts: ProbeTimeouts::DEFAULT,
367        }
368    }
369
370    /// Publish this enumerator's already-open channels into `registry` after
371    /// each settled inventory pass.
372    #[must_use]
373    pub fn with_registry(mut self, registry: ChannelRegistry) -> Self {
374        self.registry = Some(registry);
375        self
376    }
377
378    /// Subscribe this enumerator's already-open channels to lifecycle events
379    /// and coalesce them into reconciliation requests sent through `notifier`.
380    #[must_use]
381    pub fn with_event_notifier(mut self, notifier: EventNotifier) -> Self {
382        self.event_notifier = Some(notifier);
383        self
384    }
385
386    /// Warm-start this enumerator's immutable probe cache from `store`, and
387    /// write it back there whenever its persistable content changes.
388    ///
389    /// A modifier rather than a constructor: persistence is orthogonal to the
390    /// channel registry and the backend, so an enumerator can carry all three.
391    #[must_use]
392    pub fn with_probe_cache(mut self, store: Arc<dyn ProbeCacheStore>) -> Self {
393        let cache = store.load().into_entries();
394        if !cache.is_empty() {
395            debug!(entries = cache.len(), "probe cache warm-started");
396        }
397        self.cache.extend(cache);
398        self.store = Some(store);
399        self
400    }
401
402    async fn prepare_nodes(
403        &mut self,
404        backend: &dyn HidBackend,
405        candidates: Vec<NodeInfo>,
406    ) -> PreparedNodes {
407        let mut active = Vec::new();
408        let mut seen_nodes = HashSet::new();
409        let mut open_failures = Vec::new();
410        let mut retiring = Vec::new();
411        for info in candidates {
412            let node = info.id.clone();
413            seen_nodes.insert(node.clone());
414            if !self
415                .channels
416                .prepare_open(&node, |cached| Arc::strong_count(&cached.channel) == 1)
417            {
418                debug!("node still retiring — waiting for its channel's remaining users to drop");
419                retiring.push(node);
420                continue;
421            }
422            if let Some(open) = self.channels.get(&node) {
423                active.push((
424                    open.info.clone(),
425                    Arc::clone(&open.channel),
426                    open.events.as_ref().map(ChannelEventSubscriptions::handle),
427                ));
428                continue;
429            }
430            match backend.open_hidpp(&info).await {
431                Ok(Some(channel)) => {
432                    // A channel that actually opened must not inherit probe or
433                    // arrival-replay eviction counts from its predecessor.
434                    // Inventory replay remains bounded until a probe produces
435                    // a new authoritative snapshot.
436                    self.ledger.reset_channel_failures_after_open(&node);
437                    // Attach before the first feature/register check. Receiver
438                    // events are recognizable immediately; per-device feature
439                    // indexes are registered during the ensuing table walk.
440                    let events = self.event_notifier.as_ref().map(|notifier| {
441                        let protocol = find_receiver(info.vendor_id, info.product_id)
442                            .map(|receiver| receiver.protocol);
443                        ChannelEventSubscriptions::attach(&channel, protocol, notifier.clone())
444                    });
445                    let event_handle = events.as_ref().map(ChannelEventSubscriptions::handle);
446                    self.channels.insert(
447                        node,
448                        CachedChannel {
449                            info: info.clone(),
450                            channel: Arc::clone(&channel),
451                            events,
452                        },
453                    );
454                    active.push((info, channel, event_handle));
455                }
456                Ok(None) => {}
457                Err(e) => {
458                    warn!(error = ?e, "failed to open HID++ channel — requesting repair");
459                    open_failures.push(node);
460                }
461            }
462        }
463
464        // IOHIDManager can temporarily omit a Bluetooth device's vendor HID++
465        // collection while its already-open handle and ordinary mouse link are
466        // still live. Keep probing that cached channel instead of turning one
467        // incomplete OS snapshot into an offline device and stopping capture.
468        append_live_cached_channels(&mut seen_nodes, &self.channels, &mut active);
469
470        if let Some(registry) = &self.registry {
471            registry.retain_nodes(&seen_nodes);
472        }
473        self.channels.retire_absent(&seen_nodes);
474        self.channels.reap_absent(&seen_nodes, |cached| {
475            Arc::strong_count(&cached.channel) == 1
476        });
477        self.ledger.retain_nodes(&seen_nodes);
478        self.node_cache_keys
479            .retain(|node, _| seen_nodes.contains(node));
480
481        PreparedNodes {
482            active,
483            open_failures,
484            retiring,
485        }
486    }
487
488    /// Write the cache through to its store when the persistable content
489    /// changed this pass. Best-effort: a failed write is logged and retried on
490    /// the next cache-dirty pass.
491    fn flush_cache(&mut self) {
492        if !self.cache_dirty {
493            return;
494        }
495        let Some(store) = &self.store else {
496            return;
497        };
498        match store.save(&ProbeCacheSnapshot::of(&self.cache)) {
499            Ok(()) => self.cache_dirty = false,
500            Err(e) => warn!(error = %e, "failed to persist probe cache"),
501        }
502    }
503
504    /// One enumeration pass, reusing the cache from prior passes. Probes every
505    /// HID candidate concurrently (so one asleep node that burns the whole
506    /// `PROBE_TIMEOUT` can't stall the others), reusing each device's cached
507    /// immutable data when it's present and fresh.
508    ///
509    /// A node the OS still lists but whose probe fails (receiver registers
510    /// unanswered, probe timeout, open failure) is **not** reported as absent:
511    /// its last completed inventory is replayed for a bounded grace and its
512    /// channel is reopened, so a transient HID++ glitch can't masquerade as
513    /// "no devices" (#218) — see the node ledger.
514    pub async fn enumerate(&mut self) -> Result<Vec<DeviceInventory>, InventoryError> {
515        self.enumerate_reporting_completeness()
516            .await
517            .map(|(inv, _, _)| inv)
518    }
519
520    /// [`Self::enumerate`] plus whether every probed node produced a complete
521    /// enough snapshot for the one-shot caller to stop early, and whether every
522    /// probed node answered this cycle. Completeness is separate from per-node
523    /// health: a node can answer cleanly enough for the ledger to accept its
524    /// live inventory while still reporting a known count/list shortfall that
525    /// the one-shot retry should give one more chance to settle. Only healthy
526    /// shortfalls can use the unchanged-inventory early stop; failed probes must
527    /// run through the retry budget so a later attempt can recover.
528    async fn enumerate_reporting_completeness(
529        &mut self,
530    ) -> Result<(Vec<DeviceInventory>, bool, bool), InventoryError> {
531        let now = Instant::now();
532        let backend = Arc::clone(&self.backend);
533        let candidates = backend.enumerate_hidpp().await?;
534        debug!(count = candidates.len(), "HID++ candidate interfaces");
535
536        // Reuse an open channel per node, opening only when no active or
537        // retiring connection owns that OS node.
538        let PreparedNodes {
539            active,
540            open_failures,
541            retiring: retiring_nodes,
542        } = self.prepare_nodes(&*backend, candidates).await;
543        self.open_failures_last_tick = !open_failures.is_empty();
544
545        // Probe each open channel concurrently, sharing `&cache` read-only;
546        // updates are collected and applied afterwards (no `RefCell`). Each
547        // probe bounds its own I/O by the pass's timeouts (`probe_one`).
548        let results = {
549            let cache = &self.cache;
550            let timeouts = &self.timeouts;
551            active
552                .into_iter()
553                .map(|(info, channel, events)| async move {
554                    let node = info.id.clone();
555                    let pass = PassContext {
556                        cache,
557                        now,
558                        subscriptions: events.as_ref(),
559                        timeouts,
560                    };
561                    let probe = probe_one(info, Arc::clone(&channel), pass).await;
562                    (node, channel, probe)
563                })
564                .collect::<Vec<_>>()
565                .join()
566                .await
567        };
568
569        let (mut inventories, mut outcomes) = (Vec::new(), Vec::new());
570        // Aggregates for the one-shot retry. `all_complete` can stop
571        // immediately; `all_healthy` gates the unchanged-inventory shortcut so
572        // failed probes keep retrying. The ledger's own per-node replay is
573        // governed by each probe's verdict.
574        let (mut all_complete, mut all_healthy) = (true, true);
575        // Entries of nodes whose probe was deferred: neither seen nor missed
576        // this pass.
577        let mut frozen_keys = HashSet::new();
578        for (node, channel, probe) in results {
579            all_complete &= probe.verdict.is_complete();
580            all_healthy &= probe.verdict.is_healthy();
581            self.hold_or_note_cache_keys(&node, &probe, &mut frozen_keys);
582            outcomes.extend(probe.outcomes);
583            let settled = settle_probe(&mut self.ledger, &node, probe.verdict, probe.inventory);
584            // Every node waits for the ledger's consecutive-failure threshold,
585            // receivers included. One full-budget timeout is not evidence of
586            // dead delivery: [`RECEIVER_PROBE_TIMEOUT`] leaves barely a second
587            // over its own documented worst case, so a legitimate deep walk
588            // plus a single lost reply (5 s `SEND_RESPONSE_TIMEOUT`) already
589            // exceeds it. Evicting on that unpublishes *every* device behind
590            // the receiver — a Bolt publishes all six slots under one node —
591            // and tears down each one's capture plan. A channel whose delivery
592            // really is dead times out again on the repair pass and is replaced
593            // then, with the ledger replaying its last-good inventory
594            // meanwhile, so nothing disappears from the GUI in between.
595            if settled.evict_channel {
596                if let Some(registry) = &self.registry {
597                    registry.remove_node(&node);
598                }
599                if self.channels.retire_node(&node) {
600                    warn!("node probe keeps failing — retiring its channel before reopen");
601                }
602            } else if let Some(registry) = &self.registry {
603                let routes = settled
604                    .inventory
605                    .as_ref()
606                    .map_or_else(Vec::new, |inventory| {
607                        routes_for_inventories(std::slice::from_ref(inventory))
608                    });
609                if routes.is_empty() {
610                    registry.remove_node(&node);
611                } else {
612                    registry.replace_node(node.clone(), routes, channel);
613                }
614            }
615            inventories.extend(settled.inventory);
616        }
617        // A listed node whose old connection is still retiring is an unhealthy
618        // probe, not a disconnect: preserve the ledger's normal replay grace.
619        for node in retiring_nodes {
620            inventories.extend(settle_unhealthy_node(
621                &mut self.ledger,
622                &node,
623                &mut all_complete,
624                &mut all_healthy,
625            ));
626        }
627        // Nodes that wouldn't open this pass still replay their last snapshot
628        // (they have no cached channel to evict).
629        for node in open_failures {
630            inventories.extend(settle_unhealthy_node(
631                &mut self.ledger,
632                &node,
633                &mut all_complete,
634                &mut all_healthy,
635            ));
636        }
637
638        let seen_keys = self.apply_outcomes(outcomes);
639        self.evict_unseen(&seen_keys, &frozen_keys);
640        self.retry_needed_last_tick = !all_healthy || !self.misses.is_empty();
641        self.flush_cache();
642        Ok((inventories, all_complete, all_healthy))
643    }
644
645    /// Fold this pass's probe outcomes into the cache, returning the keys seen
646    /// so [`Self::evict_unseen`] can age out the rest.
647    fn apply_outcomes(&mut self, outcomes: Vec<CacheOutcome>) -> HashSet<CacheKey> {
648        let mut seen_keys = HashSet::new();
649        for outcome in outcomes {
650            match outcome {
651                CacheOutcome::Fresh(key, cached) => {
652                    seen_keys.insert(key.clone());
653                    // A completed full probe of a persistable device is worth
654                    // writing through; battery `Update`s are not (they would
655                    // rewrite the file every pass for a value that is re-read
656                    // live anyway), and neither are keys `persist::save`
657                    // filters out — dirtying on those would rewrite an
658                    // unchanged file on every refresh of a direct-only system.
659                    self.cache_dirty |= persist::is_persistable(&key);
660                    self.cache.insert(key, cached);
661                }
662                CacheOutcome::Update(key, cached) => {
663                    seen_keys.insert(key.clone());
664                    self.cache.insert(key, cached);
665                }
666                CacheOutcome::Seen(key) => {
667                    seen_keys.insert(key);
668                }
669                CacheOutcome::Unkeyed => {}
670            }
671        }
672        seen_keys
673    }
674
675    /// Fold one node's probe into the cache's per-node bookkeeping.
676    ///
677    /// A probe that ran records the entries it contributed, so a later
678    /// deferred tick knows which entries are the node's. A deferred probe
679    /// adds those to `frozen`: the entries [`Self::evict_unseen`] holds out
680    /// of miss aging this pass. A tick that never asked the node has seen
681    /// nothing and missed nothing — its empty outcomes are not the node
682    /// reporting its devices gone, and four such ticks must not delete the
683    /// last-good capabilities the ledger is still replaying the inventory
684    /// for, nor persist that deletion.
685    ///
686    /// A node deferred before this process has successfully probed it has no record
687    /// yet, but its entries may well be in the cache: a warm start loads the
688    /// persisted Bolt entries before any receiver answers. Every entry no
689    /// node has claimed is held for it then — nothing that was checked
690    /// contributed them, so nothing that was checked can have found them
691    /// missing — and the node's first healthy probe attributes what is its.
692    fn hold_or_note_cache_keys(
693        &mut self,
694        node: &NodeId,
695        probe: &NodeProbe,
696        frozen: &mut HashSet<CacheKey>,
697    ) {
698        if probe.verdict.is_deferred() {
699            match self.node_cache_keys.get(node) {
700                Some(keys) => frozen.extend(keys.iter().cloned()),
701                None => frozen.extend(self.unattributed_cache_keys()),
702            }
703            return;
704        }
705        // A failed first probe cannot establish cache ownership, even if it
706        // found some slots before failing. Keep unknown attribution distinct
707        // from a healthy probe that positively found no entries.
708        if !probe.verdict.is_healthy() && !self.node_cache_keys.contains_key(node) {
709            return;
710        }
711        let keys = probe.outcomes.iter().filter_map(CacheOutcome::key).cloned();
712        self.node_cache_keys
713            .entry(node.clone())
714            .or_default()
715            .extend(keys);
716    }
717
718    /// Cache entries no node probed by this process has contributed:
719    /// persisted entries loaded at start, until their node's first probe.
720    fn unattributed_cache_keys(&self) -> impl Iterator<Item = CacheKey> + '_ {
721        self.cache
722            .keys()
723            .filter(|key| {
724                !self
725                    .node_cache_keys
726                    .values()
727                    .any(|keys| keys.contains(*key))
728            })
729            .cloned()
730    }
731
732    /// Drop cache entries for devices not seen this pass, after a short grace so
733    /// a transient receiver timeout doesn't discard a still-present device.
734    ///
735    /// Entries in `frozen` — those of nodes whose probe was deferred — are
736    /// neither seen nor missed: their counters stand until the node is
737    /// actually probed again.
738    fn evict_unseen(&mut self, seen_keys: &HashSet<CacheKey>, frozen: &HashSet<CacheKey>) {
739        for key in seen_keys {
740            self.misses.remove(key);
741        }
742        let missing: Vec<CacheKey> = self
743            .cache
744            .keys()
745            .filter(|k| !seen_keys.contains(*k) && !frozen.contains(*k))
746            .cloned()
747            .collect();
748        for key in missing {
749            let misses = self.misses.entry(key.clone()).or_insert(0);
750            *misses += 1;
751            if *misses > CACHE_MISS_GRACE {
752                self.cache.remove(&key);
753                self.misses.remove(&key);
754                for keys in self.node_cache_keys.values_mut() {
755                    keys.remove(&key);
756                }
757                self.cache_dirty |= persist::is_persistable(&key);
758            }
759        }
760    }
761}
762
763#[cfg(test)]
764mod replay_test_support;
765#[cfg(test)]
766mod replay_tests;
767#[cfg(test)]
768mod tests;