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;