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