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;