fix: make one-shot enumerate() retry transport-agnostic so Unifying partial drains recover#287
Conversation
Greptile SummaryThis PR fixes the one-shot
Confidence Score: 5/5The change is safe to merge: it fixes a silent data-loss bug in the one-shot CLI path without touching the polling watcher, the IPC wire format, or per-node ledger replay. The retry-loop logic is correct: No files require special attention. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[enumerate free fn] --> B[Fresh Enumerator\nprevious=None, attempt=1]
B --> C[enumerate_reporting_completeness]
C --> D{probe_one per node}
D -->|Bolt| E["complete = healthy =\npaired.len == count"]
D -->|Unifying| F["healthy = count.is_some\ncomplete = paired.len == count"]
D -->|Direct| G["complete = healthy =\nwalk_succeeded"]
D -->|Timeout or open failure| H["complete = false\nhealthy = false"]
E --> I[AND-fold into all_complete / all_healthy]
F --> I
G --> I
H --> I
I --> J{one_shot_should_stop?}
J -->|all_complete = true| K[Return inventories]
J -->|all_healthy AND previous == current| K
J -->|attempt >= ONESHOT_ATTEMPTS| K
J -->|none of the above| L{all_healthy?}
L -->|yes| M[previous = current]
L -->|no| N[previous = None]
M --> O[sleep 300ms, attempt++]
N --> O
O --> C
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[enumerate free fn] --> B[Fresh Enumerator\nprevious=None, attempt=1]
B --> C[enumerate_reporting_completeness]
C --> D{probe_one per node}
D -->|Bolt| E["complete = healthy =\npaired.len == count"]
D -->|Unifying| F["healthy = count.is_some\ncomplete = paired.len == count"]
D -->|Direct| G["complete = healthy =\nwalk_succeeded"]
D -->|Timeout or open failure| H["complete = false\nhealthy = false"]
E --> I[AND-fold into all_complete / all_healthy]
F --> I
G --> I
H --> I
I --> J{one_shot_should_stop?}
J -->|all_complete = true| K[Return inventories]
J -->|all_healthy AND previous == current| K
J -->|attempt >= ONESHOT_ATTEMPTS| K
J -->|none of the above| L{all_healthy?}
L -->|yes| M[previous = current]
L -->|no| N[previous = None]
M --> O[sleep 300ms, attempt++]
N --> O
O --> C
Reviews (2): Last reviewed commit: "fix(hid): make one-shot enumerate retry ..." | Re-trigger Greptile |
| @@ -632,6 +688,7 @@ async fn probe_unifying_receiver( | |||
| paired, | |||
| }), | |||
| healthy, | |||
| complete, | |||
| outcomes, | |||
| } | |||
There was a problem hiding this comment.
Arrival-event ordering makes the stability check non-deterministic for Unifying
drain_device_arrival_unifying pushes events into out in the order they arrive over the HID channel; there is no sort. If a receiver sends events in a different slot order on two consecutive calls — e.g., slot 2 before slot 1 on the retry — the resulting paired vecs have the same devices but a different element order. Vec's PartialEq is element-wise, so previous == current returns false and the "stable healthy-but-short" early exit never fires even though the device set is identical. The loop then burns the full ONESHOT_ATTEMPTS budget instead of stopping after two passes, adding up to ~900 ms of unnecessary latency for every one-shot CLI call made against a Unifying receiver with offline paired devices. Sorting paired by slot in probe_unifying_receiver — or in enumerate_reporting_completeness before storing/comparing inventories — would make the comparison deterministic.
The one-shot `enumerate()` (CLI `list` / `diag`) retried only while a node was `unhealthy`. Bolt health is `paired == pairing_count`, but Unifying health is just "the pairing-count register answered" — so a Unifying partial arrival-drain miss reports healthy, writes the short list to last-good, and returns immediately, making the retry a silent no-op for Unifying (AprilNEA#277). Split completeness from health: `enumerate_reporting_completeness` reports both, and `NodeProbe.complete` is `paired == pairing_count` on every path. The one-shot loop now retries until the snapshot is complete, a healthy-but- short pass stabilizes across two attempts (the expected offline-Unifying case), or the attempt cap is hit. A failed/timed-out probe never counts as a "stable" read, so it keeps using the full retry budget to recover. Derives `PartialEq`/`Eq` on the inventory wire types so consecutive snapshots can be compared (no wire-format change; bincode is unaffected).
7b8f01a to
0be644e
Compare
#287 was branched before the wheel-resolution work added hires_wheel to Capabilities; squash-merging its stale diff onto the newer master left the inventory() test helper missing that field, breaking cargo test's build. Add the field so the test tree compiles again. Un-breaks the default branch.
Summary
The one-shot
enumerate()free fn (used by the CLIlist/diagpaths) retries through a transient probe miss, but its early-exit never tripped for a Unifying receiver, so a late-arriving device was silently dropped and the CLI returned a short device set.The retry loop stopped on
all_healthy, the AND of each probed node'sprobe.healthy. The two receiver kinds definehealthydifferently: a Bolt receiver setshealthy = paired.len() == pairing_count(a missing device makes it false and the retry fires), while a Unifying receiver setshealthy = pairing_count.is_some()because offline Unifying devices aren't enumerable yet. A Unifying device whose arrival event lands just after the ~1.5s drain window is dropped, yetpairing_count.is_some()stays true, soall_healthystays true and the retry never fires. The continuous GUI/agent watcher is unaffected because it re-enumerates every ~2s.Fixes #277.
Why this matters
openlogi listandopenlogi diagare the CLI's primary read paths, and a one-shot caller builds a freshEnumeratorwith an empty ledger, so it has no prior snapshot to replay a transient miss against. The retry loop was added precisely to recover from that, but for a Unifying receiver it was a no-op: a device whose arrival event lands after the drain window is dropped whilepairing_count.is_some()keepsall_healthytrue, so the loop returns a short device set on the first pass. Users on Unifying hardware intermittently see fewer devices than are actually paired, with no recovery, while Bolt users (whosehealthyrides on the count) are unaffected.Approach
Make the one-shot early-exit transport-agnostic by separating the retry signal from per-node ledger health:
completeflag toNodeProbe, reported alongside the existinghealthy. For Unifying,completerequirespaired.len() == pairing_count, so a partial drain now reportscomplete = falseand the retry fires.healthy(which drives the ledger's per-node replay) is unchanged, so the watcher and the#218replay behavior are preserved.all_complete, on an unchanged inventory between two consecutive healthy passes (the expected stable Unifying offline-drain case, so a chronically-short set stabilizes instead of burning every attempt), or on the attempt cap. The unchanged-inventory shortcut is gated onall_healthyand the previous snapshot is only retained from a healthy pass, so a failed/timed-out/open-failed probe keeps using the full retry budget to reopen its channel and recover.PartialEq/Eqderives to the inventory device structs (BatteryInfo,ReceiverInfo,DeviceModelInfo,DeviceTransports,PairedDevice,DeviceInventory) so successive attempts can be diffed. These are additive derives only; field order is untouched, so the bincode wire format is unchanged (openlogi-agent-core/tests/wire_format.rsstill passes).As a side benefit, this also stops a chronically-unhealthy node (e.g. an asleep BT-direct device that never answers) from forcing every one-shot call to burn all attempts.
Tests
Added unit tests in
openlogi-core(structural equality across nested device fields) andopenlogi-hid:cargo fmt,cargo clippy, andcargo testpass across the workspace.