Skip to content

fix(gui): don't show a multi-pid device twice in the carousel#252

Open
buliwyf42 wants to merge 1 commit into
AprilNEA:masterfrom
buliwyf42:fix/dedup-offline-placeholder-by-model
Open

fix(gui): don't show a multi-pid device twice in the carousel#252
buliwyf42 wants to merge 1 commit into
AprilNEA:masterfrom
buliwyf42:fix/dedup-offline-placeholder-by-model

Conversation

@buliwyf42

Copy link
Copy Markdown
Contributor

Problem

On 0.6.12, after Easy-Switching an MX Master 3S to another host, the device carousel showed two "MX Master 3S" cards.

Root cause

config_key is format!("{:x}{:04x}", extended_model_id, model_ids[0]) — it keys on the device's primary reported pid. A device with more than one model id reports a different primary pid per transport: the MX Master 3S is b034 over BTLE but b043 via a Bolt receiver (exactly #137). So the same physical mouse resolves to a different config_key across connections.

#224's "keep asleep devices" union (append_offline_known) re-adds a persisted-identity placeholder for every known device absent from the live list, matched only by config_key. So when the 3S was seen under both pids — a live/cached record under one, a stale persisted identity under the other — neither matched the other's key, and both showed:

config_key 0b043  →  live/cached "MX Master 3S"      (current transport)
config_key 0b034  →  offline placeholder "MX Master 3S" (stale, other pid)

Switching the mouse to another host is what surfaced it: the live record went offline/cached while the placeholder under the other pid stayed.

Fix

Dedup the offline-placeholder union by (display name, kind) as well as config_key — both against the live records and among the placeholders themselves (a device persisted under both pids must still surface once). The asset-resolved display name is per-model, so it collapses the pid variants. No schema change, no asset resolver needed in this hot path (the function stays testable in isolation, as its doc comment requires).

The edge case — two physically distinct devices of the same model, one online and one offline-elsewhere — collapses to one card while the second is away (it carries route: None, so it's unconfigurable until it returns anyway, at which point it gets its own live record). That's a far rarer and milder outcome than a phantom duplicate of one device.

Tests (cargo test -p openlogi-gui state::devices, 9 pass)

  • one_model_under_two_pids_is_not_duplicated — live record under one pid + stale identity under the other → one card; a genuinely different known device is still added.
  • one_offline_model_persisted_under_two_pids_collapses_to_one_card — both pids persisted, neither live → one card (placeholder-vs-placeholder dedup).
  • existing known_devices_are_appended_only_when_absent_from_live still passes.

cargo fmt --all -- --check clean, cargo clippy -p openlogi-gui --all-targets -- -D warnings clean.

Verification

Found and reproduced live on 0.6.12 (macOS 27 beta, MX Master 3S on a Bolt receiver). Refs #137, #224.

A device with more than one model id resolves to a different `config_key`
per transport — the MX Master 3S is pid b034 over BTLE but b043 via a Bolt
receiver (AprilNEA#137). `config_key` is `{ext_model_id}{model_ids[0]}`, so the same
physical mouse keys differently across connections, and `append_offline_known`
(the AprilNEA#224 "keep asleep devices" union) deduped persisted identities against
the live list only by `config_key`. So a 3S seen under both pids — e.g. after
Easy-Switching it to another host — showed as TWO cards: the live/cached
record under one pid plus a stale persisted-identity placeholder under the
other.

Dedup the offline-placeholder union by (display name, kind) as well as
`config_key` — both against live records and among the placeholders themselves
(a device persisted under both pids must still surface once). The resolved
display name is per-model, so it collapses the pid variants without a schema
change or the asset resolver in this hot path.

Reported live on 0.6.12: two "MX Master 3S" appeared after switching the
mouse to another host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 13, 2026

Copy link
Copy Markdown

Greptile Summary

Fixes a phantom-duplicate card in the device carousel that appeared when an MX Master 3S (or any multi-pid device) was seen under both BTLE and Bolt pids — each producing a different config_key — causing the append_offline_known union to add a stale placeholder that didn't match the live record's key.

  • append_offline_known now tracks a seen: HashSet<(String, DeviceKind)> pre-populated from the live list and gates each offline placeholder on seen.insert(), so a stale identity under an alternate pid is suppressed when a record with the same name and kind is already present.
  • The seen.insert() side-effect inside the .filter() predicate also deduplicates placeholders against each other, handling the "both pids are persisted and neither is live" path.
  • Two new tests cover both paths; the existing known_devices_are_appended_only_when_absent_from_live test continues to pass unchanged.

Confidence Score: 4/5

The change is tightly scoped to one private function and its tests; the rest of build_device_list is untouched.

The dedup logic is correct and the three unit tests cover the main scenario (live + stale pid), the all-offline scenario, and the pre-existing baseline. The seen.insert() side-effect pattern is subtle but sound. The one behavioural uncertainty — which config_key a collapsed offline placeholder gets when both pids are persisted and neither is live — has no user-visible impact while the device remains offline, and disappears the moment the device comes back online.

No files require special attention; the single changed file has good test coverage for the new paths.

Important Files Changed

Filename Overview
crates/openlogi-gui/src/state/devices.rs Adds (display_name, kind) secondary dedup in append_offline_known to suppress multi-pid phantom cards; two new unit tests verify the fix and the placeholder-vs-placeholder case

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[append_offline_known called] --> B[Build present_keys from live list config_keys]
    B --> C[Build seen from live list display_name+kind pairs]
    C --> D[Iterate over known persisted identities]
    D --> E{config_key in present_keys?}
    E -- Yes --> F[Skip: live record already covers this key]
    E -- No --> G{seen.insert display_name+kind returns true?}
    G -- No: already seen --> H[Skip: same-model phantom suppressed]
    G -- Yes: newly seen --> I[Create offline_record placeholder]
    I --> J[Add to phantoms]
    F --> D
    H --> D
    J --> D
    D --> K[drop present_keys borrow]
    K --> L[list.extend phantoms]
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "fix(gui): don't show a multi-pid device ..." | Re-trigger Greptile

Comment on lines 137 to 143
let phantoms: Vec<DeviceRecord> = known
.filter(|(key, _)| !present.contains(key))
.filter(|(key, identity)| {
!present_keys.contains(key)
&& seen.insert((identity.display_name.clone(), identity.kind))
})
.map(|(key, identity)| offline_record(key, identity))
.collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The .filter() predicate calls seen.insert() for its side effect, relying on lazy, in-order iterator evaluation to grow seen as placeholders are accepted. This is correct Rust, but the statefulness is invisible at the call site. A brief inline note would make the intent explicit and signal to future refactors that the predicate must not be reordered or parallelised.

Suggested change
let phantoms: Vec<DeviceRecord> = known
.filter(|(key, _)| !present.contains(key))
.filter(|(key, identity)| {
!present_keys.contains(key)
&& seen.insert((identity.display_name.clone(), identity.kind))
})
.map(|(key, identity)| offline_record(key, identity))
.collect();
// `seen.insert` doubles as a check-and-mark: it returns `false` (filter
// out) if the (name, kind) tuple is already present, and `true` (keep)
// while adding it so subsequent duplicates are also rejected. The side
// effect is intentional and relies on sequential, in-order iteration.
let phantoms: Vec<DeviceRecord> = known
.filter(|(key, identity)| {
!present_keys.contains(key)
&& seen.insert((identity.display_name.clone(), identity.kind))
})
.map(|(key, identity)| offline_record(key, identity))
.collect();

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

Comment on lines 137 to 143
let phantoms: Vec<DeviceRecord> = known
.filter(|(key, _)| !present.contains(key))
.filter(|(key, identity)| {
!present_keys.contains(key)
&& seen.insert((identity.display_name.clone(), identity.kind))
})
.map(|(key, identity)| offline_record(key, identity))
.collect();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Non-deterministic config_key for the winning placeholder

When both pids of the same device are persisted and neither is live (the "offline on another host" path), seen.insert() keeps whichever entry the known iterator yields first. If known_identities() is backed by a HashMap, iteration order can vary between runs, so the placeholder's config_key is non-deterministic. The offline card is unconfigurable (route: None), but the key it carries is the one used to look up saved DPI/button settings for the read-only display. The PR description explicitly acknowledges this as an acceptable trade-off — worth noting for anyone who later tries to determinise the placeholder selection.

Fix in Codex Fix in Claude Code

@buliwyf42

Copy link
Copy Markdown
Contributor Author

Verified live on the rig (macOS 27 beta, MX Master 3S Easy-Switched to another host, on a Bolt receiver).

A note on the exact trigger I reproduced — it's slightly broader than the b034/b043 framing, and this fix covers both. With the 3S offline (on another host) it returns no HID++ 2.0 model info, so build_device_list surfaces it under the fallback config_key (slot{N} / wpid…) with its pairing-register codename, while the persisted identity sits under the model-derived key (0b034). Two different keys, same resolved name → two "MX Master 3S" cards. So any Easy-Switched / asleep device whose live key falls back hits this, not just multi-pid ones — and it recurs on every online→offline cycle.

Running a build of this branch against that exact state, the dedupe converges to one card:

inventory refreshed count=4 connected_keys=["0b367", "slot3", "3b042", "0b034"]   # both 3S
inventory refreshed count=4 connected_keys=[...]                                   # merge grace holds the stale placeholder 2 ticks
inventory refreshed count=3 connected_keys=["0b367", "slot3", "3b042"]             # 0b034 evicted → one 3S

The (display_name, kind) dedupe drops the 0b034 placeholder against the live slot3 record; the ~6 s settle is just INVENTORY_MISS_GRACE aging out the initial-build placeholder. After that: a single 3S card, every cycle.

@buliwyf42

Copy link
Copy Markdown
Contributor Author

Good flag, but it doesn't apply here: known_identities() iterates Config::devices, which is a BTreeMap<String, DeviceConfig> — so iteration is sorted by config_key and fully deterministic across runs (the lexicographically-smallest key wins, e.g. 0b034 over 0b043). The HashMap caveat you describe would be the concern; with the BTreeMap it isn't one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant