Skip to content

refactor(hid): split the HID++ device layer from the host backend - #772

Merged
AprilNEA merged 14 commits into
masterfrom
refactor/hid-backend-seam
Aug 22, 2026
Merged

refactor(hid): split the HID++ device layer from the host backend#772
AprilNEA merged 14 commits into
masterfrom
refactor/hid-backend-seam

Conversation

@AprilNEA

@AprilNEA AprilNEA commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Summary

Splits the HID++ stack in two: openlogi-device holds everything that knows the protocol and nothing about a host, openlogi-hid is that layer wired to this machine. A second backend — a scripted device tree in tests, WebHID under wasm — can now exist without depending on async-hid or any platform binding.

The split is enforced rather than described. A new CI job builds the portable crates for wasm32-unknown-unknown, a target with no OS under it, so a crate that grows a filesystem, a randomness source or a thread fails there and nowhere else.

No consumer changed except the agent, which now names its backend where it starts work instead of inheriting it invisibly. The CLI's calls are untouched — openlogi_hid::set_dpi(&route, dpi) still reads the same.

Changes

openlogi-device (new) — the HID++ device layer. HidBackend (enumerate / enumerate_hidpp / open_hidpp / open_raw_writer / watch) plus BackendError, NodeId, NodeInfo, HotplugEvent, RawWriter; and above them enumeration policy, the probe, the write layer, capture sessions and pairing — all handed a backend rather than reaching for one. Its manifest lists no host dependency, which is what the wasm job checks.

openlogi-hid — down to nine files, all of them the host's: the async-hid transport (channel::transport was a submodule of the layer it sits beneath; it is now transport), the Windows composite channel, macOS Input Monitoring, the file-backed probe cache, and host, which supplies this machine's backend to the entry points that need one. Re-exports the device layer verbatim.

openlogi-hidpp — message-listener handles become a counter (they are map keys; a collision retry loop was solving a problem that does not exist), which drops rand. EventEmitter::emit uses try_send on channels that are all unbounded, where blocking could only ever mean the same call with a deadlock hazard attached.

openlogi-core — reading and writing the user config moves behind an fs feature, on by default. Everything else is data and needs no host.

xtask / ci.yml — the wasm (portable crates) job, its crate list, and the drift test that keeps the two in step.

Notes for review

  • The orphan rule moves with the seam, twice. impl From<BackendError> for WriteError was legal while BackendError was local to openlogi-hid; it now lives in openlogi-device, which owns the error. Going the other way, async_hid::HidError -> BackendError became foreign-to-foreign and is a named function again — the rule agreeing that an adapter belongs to the backend it adapts.
  • The read loop stays on a joined thread. An earlier commit here handed it back as a future; review found the case that note missed, and it is reverted. The inventory's reopen path does defer a tick, but with_route and ChannelPool do not, so dropping a channel and reopening the same route could have left two opens of one node alive — the failure SW_ID_LEASES and RECEIVER_PROBE_BUDGET's doc both exist for. Removing the thread is worth doing against real hardware, in its own change.
  • run_capture_session_with_registry is deleted rather than given a backend: nothing called it, it ignored the registry it was named for, and its doc said the reuse it promised was still TBD.
  • What the wasm job does not prove. It is a check. std::thread::spawn and tokio::time both compile for wasm and fail when run, so this gates dependencies, not execution. The job's caveat says so.
  • The drift test had to change. The wasm job skips itself where the wasm32 std is absent, so comparing its planned argv against ci.yml panicked on CI's test runners while passing locally. It now joins the four jobs already excluded for that reason, and a narrower test asserts every crate declared portable appears in the workflow command.

Testing

Full local gate on the rebased tree:

cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --document-private-items \
  --exclude openlogi-ui --exclude openlogi-desktop --exclude openlogi-overlay --exclude openlogi-agent
cargo test -p openlogi-ipc --test wire_format
cargo xtask ci wasm clippy-windows cargo-deny

All green. Wire format unchanged — no serde variant moved, so PROTOCOL_VERSION is untouched.

Two tests were added for behaviour that previously needed hardware to observe: a node that will not open settles as an unhealthy probe so the ledger replays its last-good snapshot, while a node that opens but is not HID++ leaves the tick healthy. Verified the first fails when the open-failure settle loop is short-circuited, so it pins behaviour rather than shape.

Not runtime-tested on hardware. The reader-lifetime change above is the part that matters there: plug in a Bolt receiver and a direct BLE device, confirm both still enumerate, that DPI/SmartShift writes land, and that unplugging and replugging a receiver reopens it cleanly.

tests (linux) and clippy (ubuntu) not run locally — this host has no musl target, so Linux cfg is CI's alone to check.

`async_hid::HidError` was part of this crate's public API — in
`InventoryError`, `GestureError`, `HostSwitchError` and `ChannelPool::open`
— which put "openlogi-hid uses async-hid" into its contract rather than its
implementation. `openlogi-agent-core` had picked up a dev-dependency on
async-hid purely to construct one of those errors in a test.

Introduce `backend::BackendError`, a two-variant backend-agnostic error, and
convert at the one place that owns the backend (`channel::transport`).
Nothing above the transport names `async_hid` any more.

Being local to this crate, `BackendError` also clears the orphan rule that
forced `classify_hid_error` on `WriteError` and `PairingError`: both are now
real `From` impls, so nine `.map_err(|e| classify_hid_error(&e))?` call
sites collapse to plain `?`.

No wire change: the three error enums touched are not `Serialize`, and
`WriteError`/`PairingError` gained a conversion, not a variant.
`async_hid::DeviceId` and `DeviceInfo` were the crate's node-identity and
node-metadata types all the way up: cache and ledger keys, the registry, the
probe's per-node record, the standalone descriptor. Every one of those layers
is pure HID++ policy that has no business naming a HID stack.

Introduce `backend::{NodeId, NodeInfo}` as the value types those layers hold,
converted once in `channel::transport`. `NodeInfo` carries exactly the fields
anything above the transport reads — the intersection any HID backend can
supply. `device_identity` becomes `NodeInfo::identity`, where it belongs.

`NodeId` renders the backend's opaque id verbatim, so the identity string
raw-HID routes are addressed by is byte-identical to before.

Also moves `HotplugEvent` to `backend` and the async-hid watch bridge into
`channel::transport`, which lets `hid_backend()` stop being crate-visible.

`async_hid` now appears outside `channel::transport` in exactly two places,
both of which need the open/write seam that comes next: the `Device` handle
`prepare_nodes` consumes, and the `DeviceWriter` the Litra raw-write path uses.
Enumeration and opening reached the OS through free functions over a
process-wide `static`, which is why the layers above could not be tested
without hardware and why `async_hid::Device` had to travel up to
`prepare_nodes` to be opened there.

Add `backend::HidBackend`: enumerate, enumerate_hidpp, open_hidpp,
open_raw_writer, watch. `NativeBackend` implements it over async-hid and is
now the only place that names that crate. Opening is defined only for a node
a previous enumerate returned, so the backend keeps that enumeration's OS
handles instead of re-finding a node it cannot rebuild from a `NodeId`.

Route policy moves off the backend and onto the trait: `open_route_channel`
and `open_route_writer` (relocated out of `channel::transport`, where it never
belonged) both take `&dyn HidBackend`. The Litra raw-write path goes through
`backend::RawWriter` rather than an `async_hid::DeviceWriter`.

The public free functions keep their signatures by passing `native_backend()`;
injecting a backend into `Enumerator` and `ChannelPool` — the part that buys
testability — is the next step.

`async_hid` now appears outside `channel::transport` in zero places.
`Enumerator` and `ChannelPool` reached the host through a process-wide
accessor, so neither could be pointed anywhere else — which is why the
inventory tick, the largest piece of logic in this crate, had no test that ran
it end to end.

Both now hold an `Arc<dyn HidBackend>`; `Default` still resolves to the host's,
so no caller changes. `ScriptedBackend` joins the existing scripted transport
in the test-only module, and two tests pin behaviour that previously needed
hardware to observe: a node that will not open settles as an unhealthy probe
(so the ledger replays its last-good snapshot), while a node that opens but is
not HID++ leaves the tick healthy. Conflating those two would make every host
with an unrelated HID device retry forever.

Verified the first test fails when the open-failure settle loop is short-
circuited, so it is pinning behaviour and not the implementation's shape.
The enumerator held a `PathBuf` and called `std::fs` directly, so the one
piece of state worth keeping across restarts was welded to a filesystem —
a host detail sitting in the middle of protocol logic.

Split it the way the HID stack is split: `persist` now owns only the
persistable *form* (`ProbeCacheSnapshot`, the schema version, the Bolt-only
rule) and the `ProbeCacheStore` port; `persist::file` owns the JSON-on-disk
implementation. `Enumerator` holds an `Option<Arc<dyn ProbeCacheStore>>` and
`persisted()` keeps meaning what it meant.

The schema-version check moves into `into_entries`, next to the schema it
guards, so a store only has to hand back bytes it managed to read.
Portability was an intention with nothing enforcing it. Now a compiler does:
`wasm32-unknown-unknown` has no OS beneath it, so a crate that grows a
filesystem, a randomness source or a thread stops building there and nowhere
else. Added as a real CI job plus a `cargo xtask ci wasm` lane, with the crate
list in xtask and the ci.yml drift test holding the two in step.

Two things had to go for `openlogi-hidpp` to pass, both improvements on their
own terms:

- Message-listener handles were random `u32`s with a collision retry loop.
  They are keys into a per-channel map, so a counter is collision-free by
  construction — and `rand` leaves the crate entirely.
- `EventEmitter::emit` used `send_blocking` on channels that are all
  unbounded, where it can only ever mean `try_send` with a deadlock hazard
  attached (it runs holding the sender lock).

The list is one crate. `openlogi-core` cannot join it yet: its config half
writes through `atomic-write-file`, which pulls `getrandom`. That is the next
split, and the comment on `WASM_PORTABLE_CRATES` says so.

Note what a check does not prove: `HidppChannel` still spawns an OS thread for
its read loop, which compiles for wasm and would fail at runtime. Making that
loop a task is separate work.
`openlogi-core` is described as data — the device model, the action
catalogue, the binding types — but it also read and wrote the user's config,
which dragged in `atomic-write-file` → `getrandom` and made the whole crate
unbuildable anywhere without a filesystem.

Gate that half: `config::file`, `paths` and `single_instance` are now behind
`fs`, on by default so every consumer here is unaffected. What is left needs no
host at all, and the wasm CI job checks that with a `--no-default-features`
pass rather than trusting the crate description.

Two fields stay in every build with a cfg'd `dead_code` allow rather than being
gated out: `Config::ephemeral` and `DeviceConfig::gesture_owner` are read only
by the load/save half, but they are part of the shape serde sees, and a struct
whose fields depend on a feature is a struct with two shapes.
Every open HID++ channel spawned an OS thread and blocked it on a private
executor. That costs a thread per device, hard-codes an executor choice into a
protocol crate, and cannot exist at all on a single-threaded target.

`from_raw_channel` now returns the channel *and* its reader future; the caller
spawns it. `openlogi-hid` does that with `tokio::spawn` at both open sites, so
the reader runs on the runtime that already exists rather than one of its own.

The behavioural change worth knowing: `Drop` used to signal the reader and then
join the thread, so the OS handle was closed by the time drop returned. It now
signals and returns, and the handle closes when the task winds down — which is
the reader's next poll, since it is parked in a `select!` on that very signal.
The inventory's reopen path is unaffected either way: a retired node is
deferred to a later tick (~2 s) *and* until its channel's last `Arc` is gone,
neither of which was ever the thread join.

Tests keep a thread, on purpose: they run on `futures::executor::block_on`,
which polls one future, so `channel_with_reader` gives the reader an executor
without pulling a runtime into this crate's dev-dependencies. That the library
no longer imposes one is the point.

Not runtime-tested on hardware — the close-timing change is the part that
would want a real device.
The seam existed as a module inside the crate that implements it, so a second
backend — WebHID under wasm, a scripted tree — would have had to depend on
`openlogi-hid`, `async-hid` and every host binding to obtain the trait it means
to implement. It is now its own crate, and CI's wasm job holds its manifest to
having no host in it at all.

Splitting it moved the orphan rule, which is the part worth reading twice.
`impl From<BackendError> for WriteError` had been legal in `openlogi-hid`
because `BackendError` was local there; now that it is not, the impls move to
`openlogi-device`, which is where the layering wanted them anyway — the crate
that owns the error owns its conversions, and `openlogi-core` still depends on
no backend. That is why `openlogi-device` takes `openlogi-core` (with `fs`
off): the first edge of the graph the rest of the layer will move along.

Going the other way, `async_hid::HidError -> BackendError` and
`&DeviceInfo -> NodeInfo` are now foreign-to-foreign in `openlogi-hid`, so they
become named functions again. That is the rule agreeing with the design: an
adapter belongs to the backend it adapts, not to the contract.

`openlogi-hid` re-exports the contract as `backend`, so every existing path
still resolves and no consumer changed.

Probed the gate by pointing the new crate at `openlogi-core` with default
features: it fails on `getrandom`, which is the regression class it is for.
Every route-addressed write reached for the process-wide native backend from
inside, so none of them could be driven by anything else — the last thing
pinning that layer to this host.

They now take `&dyn HidBackend`, and `host` holds the twenty-one one-line
wrappers that supply this machine's. Consumers are untouched: the crate root
re-exports the route-addressed half from `host` and the channel-addressed `_on`
half straight from `write`, which is the same split the `_on` suffix has been
drawing by hand all along — one needs a backend to find a device, the other is
handed one already open.

`with_route`, the twelve-line function that was the write layer's entire
contact with the OS, is now the only place a backend is threaded through.
The enumerator, the channel pool, pairing, the capture sessions, standalone
discovery and hotplug each reached for the process-wide native backend from
inside. None of them could be pointed at anything else, and none of them could
move out of a crate that knows what a host is.

They all take a backend now. `Enumerator` and `ChannelPool` lose the `Default`
impls that were doing the reaching — a default that resolves to *this machine*
is not a default, it is a decision — and `Enumerator::persisted`, which picked
a filesystem path, moves to `host` beside the other host wiring.

Six call sites changed, all in the agent, and each one now names its backend
where it starts the work rather than inheriting it invisibly.

Deleted `run_capture_session_with_registry` rather than giving it a backend
too: nothing called it, it ignored the registry it was named for (`_registry`),
and its own doc said the reuse it promised was still TBD.
Enumeration, probing, the write layer, capture sessions and pairing knew HID++
and nothing about a host, but lived in the crate that owns `async-hid`. They
move to `openlogi-device`, which the wasm job now builds in full — so
"portable" is a fact about that crate rather than a description of it.

`openlogi-hid` is what is left: the `async-hid` transport, the Windows
composite channel, macOS Input Monitoring, the file-backed probe cache, and
`host`. It re-exports the device layer verbatim, so every consumer path still
resolves and no consumer changed.

Two things moved to where they belonged rather than where they were:

- `channel::transport` was a submodule of the channel layer it is beneath. It
  is now `openlogi-hid`'s `transport`, and `openlogi-device::channel` names no
  HID stack at all.
- The probe-cache round-trip test was checking `ProbeCacheSnapshot::of` and
  `into_entries` — schema, Bolt-only keys, the volatile battery reading —
  through a file, as if the file were the subject. Those assertions move to
  `persist`'s own tests; what stays in `openlogi-hid` is what the file store
  actually owns: a missing or torn file is a cold start, and saving creates the
  data dir on a first run.

`ProbeCacheError::new` and `ProbeCacheSnapshot::is_empty` are new: a store
implemented outside the crate that defines them needs both, which is the
crate boundary doing its job.
Copilot AI lite review requested due to automatic review settings August 21, 2026 19:54
@AprilNEA
AprilNEA requested a review from davidbudnick as a code owner August 21, 2026 19:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@greptile-apps

greptile-apps Bot commented Aug 21, 2026

Copy link
Copy Markdown

Greptile Summary

The PR separates the portable HID++ device layer from the host transport and adds a wasm portability gate.

  • Introduces openlogi-device for backend-independent inventory, writes, sessions, and pairing.
  • Retains host-specific transport and persistence in openlogi-hid.
  • Restores synchronous reader-thread shutdown so reopening a HID node cannot overlap its retired handle.
  • Adds feature-gated filesystem support in openlogi-core and corresponding CI/xtask coverage.

Confidence Score: 3/5

The PR is not yet safe to merge because the new wasm job still executes mutable third-party action references.

The reader-lifetime defect has been fixed by joining the owned reader thread during channel destruction, but the wasm CI job continues to resolve third-party action tags dynamically, leaving its executed code vulnerable to upstream tag movement.

Files Needing Attention: .github/workflows/ci.yml

Important Files Changed

Filename Overview
crates/openlogi-hidpp/src/channel.rs Keeps the HID++ read loop on an owned thread and joins it during channel destruction, addressing the previously reported reopen overlap.
crates/openlogi-hid/src/transport.rs Implements the host-specific async-hid backend beneath the new backend-neutral device layer.
crates/openlogi-device/src/backend.rs Defines the backend-neutral node, writer, hotplug, and transport abstractions used by portable device logic.
.github/workflows/ci.yml Adds the wasm portability job, but its third-party action references remain mutable as previously reported.
xtask/src/commands/ci/jobs.rs Registers the wasm portability gate in the local CI runner and its job metadata.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Agent[openlogi-agent-core] --> Host[openlogi-hid host API]
  Host --> Device[openlogi-device]
  Host --> Transport[async-hid transport]
  Device --> Protocol[openlogi-hidpp]
  Transport --> Hardware[Host HID devices]
  Protocol --> Hardware
Loading

Reviews (2): Last reviewed commit: "refactor(hidpp): keep the read loop on a..." | Re-trigger Greptile

Comment thread crates/openlogi-hid/src/transport.rs Outdated
configure_channel_sw_ids(&mut c);
// The channel is inert until its reader runs; dropping the
// channel is what ends the task.
tokio::spawn(reader);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Detached reader overlaps node reopen

When inventory evicts an unhealthy channel, the detached reader still owns the raw HID handle after HidppChannel::drop returns, while prepare_nodes immediately opens the same node again. This can direct replies to the retired handle and leave the replacement channel timing out or repeatedly cycling through eviction.

Knowledge Base Used:

Fix in Codex Fix in Claude Code

Comment thread .github/workflows/ci.yml
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable

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 security Third-party actions use mutable tags

The new wasm job executes dtolnay/rust-toolchain@stable and, at line 254, Swatinem/rust-cache@v2 through mutable tags. Pin both third-party actions to reviewed commit SHAs so upstream tag movement cannot silently change the code executed by this CI gate.

How this was verified: Both changed workflow steps resolve third-party action tags at job startup rather than immutable commit SHAs.

Rule Used: Flag third-party action tag pinning as higher prio... (source)

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

`ci_yml_runs_what_this_runner_runs` built each job's plan and compared its
argv, but the wasm job skips itself on a machine without the wasm32 std — so
on CI's test runners, which install no such target, it planned no steps and
the comparison panicked. It passed locally only because devenv installs the
target. My-machine-green, which is the same trap as macOS-green.

The wasm job joins the four jobs already excluded for choosing their
invocation from what the machine has. What replaces the comparison is
narrower and stronger: `wasm_checks_the_crates_ci_checks` asserts every crate
declared portable appears in the workflow's command, so a crate added to the
list but not to `ci.yml` fails a test rather than silently going unchecked.

Verified by removing `openlogi-device` from the workflow command: the new test
fails, naming the crate.
Reverts the reader-as-a-future half of "hand the read loop back as a future".
The `rand` removal and the `try_send` fix from that commit stay; only the
threading changes back.

The join was not politeness. It is what makes the OS handle closed by the time
`HidppChannel::drop` returns, and the review found the case my own note
missed: the inventory path defers a reopen by a full tick, but `with_route`
and `ChannelPool` do not — a caller that drops a channel and reopens the same
route can have two opens of one node alive at once. This tree has been bitten
by exactly that before; `SW_ID_LEASES` exists for it, and
`RECEIVER_PROBE_BUDGET`'s doc describes the macOS failure where replies reach
only one of two open handles.

What the change bought was a thread per channel and a step toward running on
a single-threaded target. Neither is worth an unverifiable regression on the
path that ships, and neither is needed by the split this PR is about. It
belongs in its own change, where it can be tried against real hardware.

The wasm job's caveat goes back to naming `std::thread::spawn`, which is once
again the concrete example of something that compiles for wasm and fails when
run.
@davidbudnick davidbudnick added the platform: all Cross-platform issue label Aug 21, 2026
@davidbudnick davidbudnick added this to the v0.7.5 milestone Aug 21, 2026
@AprilNEA
AprilNEA merged commit be94e6f into master Aug 22, 2026
29 checks passed
@AprilNEA
AprilNEA deleted the refactor/hid-backend-seam branch August 22, 2026 23:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

platform: all Cross-platform issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants