Skip to content

fix(hid): park the Windows HID read on a permanently dead handle - #779

Open
yuzi-co wants to merge 2 commits into
AprilNEA:masterfrom
yuzi-co:fix/windows-hid-disconnect-spin
Open

fix(hid): park the Windows HID read on a permanently dead handle#779
yuzi-co wants to merge 2 commits into
AprilNEA:masterfrom
yuzi-co:fix/windows-hid-disconnect-spin

Conversation

@yuzi-co

@yuzi-co yuzi-co commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Unplugging the cable of a mouse that is also paired over its receiver left
openlogi-agent burning 100% of a CPU core forever. Replugging did not clear it,
and every unplug leaked another spinning thread.

RawHidChannel::read_report documents that an Err is transient — the hidpp
read loop logs it and retries — so an implementation must not surface a condition
that will never clear, and should park instead. AsyncHidChannel (the
cfg(not(windows)) transport) honours that. WindowsHidppChannel never did: it
propagated async_hid::HidError::Disconnected straight into the retry loop,
which then spun at ~14,000 iterations per second.

The same impl also never overrode is_connected, so it reported the trait
default of true for the rest of the process's life. inventory::ledger
therefore never learned the channel was dead, CHANNEL_EVICT_AFTER never fired,
and the gesture watcher re-armed the vanished route once a second indefinitely.
Node-vanish eviction could not cover for it either — the receiver keeps the HID
node enumerated after the cable goes, which is exactly the case ledger.rs
calls out as needing is_connected.

macOS and Linux are unaffected; they compile the other implementation.

Changes

openlogi-hidcrates/openlogi-hid/src/channel/transport/windows.rs

  • WindowsHidppChannel::read_report parks on HidError::Disconnected in all
    three read paths — both tokio::select! arms and the single-endpoint arm —
    instead of returning an error the read loop retries.
  • Track the state in a connected: AtomicBool, cleared by mark_disconnected,
    and report it through a new is_connected override so the inventory ledger
    can evict the channel.
  • write_report marks the disconnect too. HidEndpoint::write_report boxes the
    async_hid error before the channel sees it, so that path recognises the
    variant by downcast; a plain matches! there would silently never fire, which
    is what the new unit test pins down.
  • HidEndpoint::write_report no longer attempts the native Windows fallback on a
    Disconnected handle. That fallback works around async-hid output-report
    quirks on a live device; reopening a device that is gone can only fail, and
    the NativeWriteError it returned replaced the typed Disconnected the
    channel needs to retire itself (review finding, c49ee67).

Testing

cargo fmt --all -- --check                                                      # pass
RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets -- -D warnings    # pass
RUSTFLAGS="-D warnings" cargo test --workspace                                   # pass, except the pre-existing failure below
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items \
  --exclude openlogi-ui --exclude openlogi-desktop --exclude openlogi-overlay --exclude openlogi-agent

Two pre-existing failures on this host, both unrelated to this diff (which
touches one file in openlogi-hid):

  • xtask's commands::ci::jobs::tests::ci_yml_runs_what_this_runner_runs — also
    fails on master with the change reverted.
  • the workspace rustdoc run stops on openlogi-permissions
    (unresolved link to PermissionStatus::Unknown, from 0f028a8), the known
    Windows-only breakage. cargo doc -p openlogi-hid --no-deps --document-private-items under RUSTDOCFLAGS="-D warnings" is clean.

The Linux/macOS cross-lint from .claude/rules/cross-platform.md could not run
locally — only x86_64-pc-windows-msvc is installed on this host and devenv is
unavailable. Audited by hand against master instead: every item added here sits
inside a #[cfg(target_os = "windows")] block, the only ungated items in the file
are pre-existing and untouched, and the new names (is_permanent_disconnect,
park_disconnected, the connected field) collide with nothing.
mark_disconnected shares a name with AsyncHidChannel's, on a different type in
the cfg(not(windows)) impl. CI's clippy, clippy (windows), tests (linux),
tests (macos) and MSRV lanes are green, which is the actual confirmation.

Verified on hardware (Windows 11, G502 LIGHTSPEED on receiver c539 plus USB
cable c08d), running each patched build through the exact repro — dongle
connected, cable added, cable removed — and sampling the agent for four minutes
afterwards:

signal before 9e2c214 c49ee67
peak agent CPU, any 5 s window 100% of a core, indefinitely 1.2% 2.8%*
samples flagged as spinning every one 0 0
read_report error trace lines 14,248 in 1.2 s 0 0
gesture re-arming warnings 1 Hz, forever 0 0
HID channel disconnected never logged 1 2
native-fallback attempts on the dead handle 3 3 0
AllMethodsFailed 1 1 0

* the 2.8% sample is at agent startup, before the cable cycle; every
post-disconnect window is at or below 0.06 s.

HID channel disconnected rising from one to two on c49ee67 is the write path
marking the disconnect instead of losing it to NativeWriteError.

The device recovers rather than wedging: the probe fails transiently, the ledger
replays the last-known identity, and the receiver path re-enumerates the G502 on
slot 1 with capture re-arm generation=2.

Fixes #777

`RawHidChannel::read_report` documents that an `Err` is transient — the
`hidpp` read loop logs it and retries — so an implementation must not
surface a condition that will never clear. The non-Windows transport
honours that by parking on `HidError::Disconnected`; the Windows one
propagated it, so unplugging the cable of a mouse that is also paired
over its receiver spun the read loop at 100% of a core indefinitely,
14k iterations per second, and every unplug leaked another such thread.

`WindowsHidppChannel` also never overrode `is_connected`, so it reported
the trait default of `true` forever. Inventory therefore never learned
the channel was dead, `CHANNEL_EVICT_AFTER` never fired, and the gesture
watcher re-armed the vanished route once a second for the life of the
process. The receiver keeps the HID node enumerated once the cable goes,
so node-vanish eviction could not cover for it either.

Park on `Disconnected` in all three read paths, track the state in an
`AtomicBool`, and report it through `is_connected`. `write_report` marks
it too, by downcast — `HidEndpoint::write_report` boxes the `async_hid`
error before the channel sees it, so a plain `matches!` there would
silently never match, which is what the new test pins down.

Fixes AprilNEA#777
@yuzi-co
yuzi-co requested a review from AprilNEA as a code owner August 21, 2026 21:34
@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown

Greptile Summary

The PR fixes Windows HID channel retirement after a permanent disconnect.

  • Returns HidError::Disconnected before the native fallback can replace it.
  • Marks disconnected channels as no longer connected so inventory can evict them.
  • Parks permanently failed reads instead of feeding a busy retry loop.
  • Adds coverage for recognizing boxed disconnect errors.

Confidence Score: 5/5

The PR appears safe to merge.

The previously reported fallback-masking failure is fixed because a typed Disconnected now bypasses the native fallback and reaches mark_disconnected; no blocking failure remains.

Important Files Changed

Filename Overview
crates/openlogi-hid/src/channel/transport/windows.rs The early return preserves the typed disconnect through the write path, allowing the existing downcast and channel-retirement logic to address the prior finding.

Reviews (2): Last reviewed commit: "fix(hid): skip the native write fallback..." | Re-trigger Greptile

Comment thread crates/openlogi-hid/src/channel/transport/windows.rs
@davidbudnick davidbudnick added this to the v0.7.5 milestone Aug 21, 2026
The fallback exists to work around async-hid output-report quirks on a
live device. Reopening a device that is gone can only fail, and the
`NativeWriteError` it returns replaces the typed `HidError::Disconnected`
the caller needs — so `is_permanent_disconnect` missed it and the channel
kept reporting itself connected until the read path caught up.

Not hypothetical: in the hardware run for the parent commit the fallback
fired three times, all after the disconnect and none before, ending in
`AllMethodsFailed`. Surface `Disconnected` directly instead.
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.

Unplugging the cable on a dual-transport mouse pins a CPU core on Windows

2 participants