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