Skip to content

fix(agent): stabilize Easy-Switch forwarding - #2

Draft
noobydp wants to merge 364 commits into
codex/pr-970-stagingfrom
fix/easy-switch-forwarding
Draft

noobydp wants to merge 364 commits into
codex/pr-970-stagingfrom
fix/easy-switch-forwarding

Conversation

@noobydp

@noobydp noobydp commented Aug 28, 2026 •

Copy link
Copy Markdown
Owner

Fork staging PR: The upstream draft is AprilNEA#1122. This base
mirrors current upstream master plus AprilNEA#970, so the fork PR shows only the
single Easy-Switch reliability commit.

Summary

Re-home the analytics-only keyboard work from AprilNEA#718 onto the
current host-switch architecture while preserving Samuel Phinizy's authorship.

Make keyboard-initiated linked switching react promptly to receiver-exclusive
operations, handle keyboards that begin departing before normal probing or
cleanup completes, and prevent stale session completions from moving followers.

Changes

  • openlogi-device - model commanded and already-departing keyboard
    transitions explicitly, validate reported host slots, restore analytics
    capture under a short latency budget, and move followers through their own
    channels when the keyboard has already left.
  • openlogi-agent-core - count overlapping exclusive receiver requests,
    wake capture managers immediately, preserve announcement-aware reconnect
    capture, and reject obsolete session generations before they can start a
    transition.
  • tests - cover empty host slots, departed keyboards, announcement capture,
    bounded analytics cleanup, exclusive-access wakeups, and stale completions.

Testing

  • cargo fmt --all -- --check
  • RUSTFLAGS="-D warnings" cargo clippy --workspace --all-targets -- -D warnings
  • RUSTFLAGS="-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
  • RUSTFLAGS="-D warnings" cargo check -p openlogi-hidpp -p openlogi-device --target wasm32-unknown-unknown
  • typos was not run locally because typos-cli is unavailable.
  • Not runtime-tested on physical hardware at this final head. Earlier mixed
    OpenLogi/Options+ testing covered forward and reverse switching, but the
    three-PC rapid-switch and sleeping-destination cases still need a clean
    OpenLogi-only retest.

Continues AprilNEA#718

Depends on AprilNEA#970

Code and PR drafted with Codex

@noobydp
noobydp force-pushed the codex/pr-970-staging branch from 0bb38cc to dd35a58 Compare August 28, 2026 18:01
@noobydp
noobydp force-pushed the fix/easy-switch-forwarding branch 13 times, most recently from 335f1b6 to 5408594 Compare August 31, 2026 08:54
isleofgreg and others added 15 commits September 16, 2026 14:34
The software-id allocator was a per-process static, so the agent and any
other OpenLogi process opening the same HID node (a CLI diag run, the GUI)
both leased id 1. macOS, Windows and Linux hand every input report to
every open handle, so each channel saw the other's replies, and a reply
whose header matched a pending request was taken as its answer. A root
getFeature reply carries no feature id: one taken from the other process
pinned the wrong feature index for the rest of the session — DPI writes
failed with InvalidFunctionId, control diverts with InvalidArgument, and
the agent's control capture came up with no buttons until restarted with
no other process talking to the device.

Add openlogi_device::host_lock — exclusive OS file locks under the temp
directory (std File::try_lock; no new dependency, no unsafe) — and back
each lease with one, so a second process skips ids the first holds. The
OS releases the lock with its holder, so a crash cannot strand an id. If
the lock directory is unusable the lease falls back to the process-local
table, reported once.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…sses

HID++ 1.0 register replies carry no software id, and an empty-slot error
reply carries no sub-register byte either, so two OpenLogi processes
probing the same receiver at once still took each other's replies after
the sub-register echo check: each probe's real slot read was answered by
the other probe's empty-slot error, both probes reported the receiver
with no devices, and the agent retired a channel that was fine (dropping
its control capture for the re-arm).

Hold a host_lock keyed by the node's identity for the register phase of
a Bolt or Unifying probe — unique id, pairing count, arrival drain, slot
identities — bounded at 5 s, past which the probe proceeds unlocked as
before. The per-device feature walks stay unlocked: they address each
device by index under the process's own software id.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
A reply is matched to the oldest pending request whose predicate accepts
it, and every predicate keys on the report's first three bytes: device,
feature (HID++1.0: sub id), function and software id (HID++1.0: register
address). Two requests sharing those bytes get replies nothing on the
wire can tell apart, and a wireless receiver answers them out of order
when a radio packet is retransmitted. The agent's startup runs several
device sessions at once — the settings writer, the gesture capture, and
any GUI request that lands over IPC — each resolving features through
root getFeature with the identical header. On a Bolt-connected MX Master
4 the trace shows the thumbwheel's index (0x2150) handed to the wheel
session and the wheel's (0x2121) to the thumbwheel session: the wheel is
then 'unsupported', DPI writes fail with InvalidFunctionId, the control
divert with InvalidArgument, and the capture comes up with no buttons.

Park a request whose header is already in flight until that request is
answered, cancelled, or times out; the wait counts against the caller's
timeout. The check and the registration happen under one lock, so two
parked requests woken together cannot both slip in.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
rustdoc runs with `-D warnings` in CI, and `read_long_sub_register`'s
public docs linked `is_rap_response`, which is private. Name it in code
font instead.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
The software-id lease was keyed on the id alone, so every HID node on the
host drew from one pool of fifteen: with fifteen channels open across
OpenLogi processes, an open on an unrelated node was refused — no live
reads, writes or capture for that device until some channel elsewhere
closed. Report streams of different nodes never meet, so ids only have
to be unique per node.

Leases are now `(node, id)`: the in-process table is keyed that way and
the OS lock file is named after the node's own lock name, beside its
register-phase lock, in the one host lock directory `host_lock` already
owns — which also retires the transport's private copy of the
file-locking code. The channel's `Leased` policy carries the lease as an
owned handle dropped with the policy instead of a bare `fn(u8)` callback,
since a free function cannot tell which node's id it is returning.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…ly lands

Serialising same-header requests left one hole: a request that timed out
or was cancelled unanswered freed its header at once, and its reply —
still on its way — then answered the next request with that header. The
response predicate has no generation to tell them apart, so two distinct
same-header calls could still swap replies; a receiver answering a
register read a few hundred milliseconds after a tight probe budget gave
up on it is the realistic case.

An abandoned request now leaves its header reserved until its reply
arrives and is discarded, or for STALE_REPLY_GRACE (1 s) at most, so a
device that never answers costs the next request one grace window rather
than a whole timeout. A byte-identical re-ask — what the feature-table
read does when the link drops a report — does not wait: the outstanding
reply answers it as well as its own would, so it goes out at once,
adopts the owed reply, and is answered by whichever comes first; the
channel keeps counting the other so a later, different request cannot be
answered by it either.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…lock

`lock_within` fell through to an unlocked probe once the wait budget
passed, on the theory that the holder was stuck. A holder still going
past five seconds is more often a live probe failing slowly — each
unanswered register read costs it a request timeout — and joining it
unlocked restored the exact race the lock exists for: the second process
consumed the first's header-only HID++ 1.0 error replies.

Contention past the budget now settles a failed probe instead: "couldn't
check", which the ledger already handles by replaying the node's last
snapshot and retrying next tick. `lock_within` distinguishes that from
the lock directory being unusable, the one case with nothing to wait for,
where the probe runs unarbitrated as it did before locks existed.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…ailure

A probe that could not take the receiver's register-phase lock settled as
a failed probe, so each such tick advanced the node's failure count: two
consecutive contentions retired the channel and tore down capture behind
it, although this process had skipped the node's I/O entirely and had no
evidence the channel was unwell.

Deferral is now its own verdict. The ledger replays the node's last-good
inventory without touching the failure count or requesting eviction — a
channel that was never asked cannot have failed — while the verdict
still fails the pass's `all_healthy`, so the one-shot retry re-probes as
it would after a failure.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…dentical re-ask

Byte-identical requests do not have interchangeable replies. A DPI read
abandoned before its reply, a DPI write acknowledged, and the read re-asked
all fit inside one STALE_REPLY_GRACE, and the unconditional re-ask exception
let the re-ask adopt the first read's late reply — the value from before the
write — while its own reply was counted as the stale one.

The channel now quarantines by default whatever the new request's bytes are.
Adoption is an explicit choice of the caller, AbandonedReply::AdoptIdentical
on send_with / send_v20_with / FeatureEndpoint::call_with, and the only
caller that makes it is the FeatureSet feature: its table is fixed for the
life of the connection, so a walk that re-asks for an entry the link dropped
is not held for the grace on every retry (without it, half the re-ask
attempts of read_feature_entry never reached the wire).

Tests: the read-after-write case through send_v20 and the mock; the default
policy parking an identical re-ask; the opt-in adopting the owed reply; and
that the abandoned request's own policy plays no part.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
The register-phase lock only works if conflicting callers participate, and
two did not: open_route_channel and pairing's receiver selection both read
the receiver's unique id — a HID++ 1.0 register read — unlocked, and a
pairing session writes the notification, discovery and pairing registers for
its whole run.

The phase moves out of the inventory probe into host_lock as
lock_receiver_registers / ReceiverRegisterPhase, with one wait
(RECEIVER_REGISTER_WAIT, 8 s) for every taker. A route open whose receiver
is held elsewhere past the wait passes the node over this time — the caller
sees the same None as for a receiver that is not there — rather than read
into the holder's replies. Pairing takes the phase once in open_receiver and
holds it through the session's last restore write; inventory probes in every
process defer to it meanwhile and pick the new pairing up on release.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…t starts

RECEIVER_PROBE_BUDGET (13 s) was sized for a receiver probe's honest worst
case — the 1.5 s arrival drain, the register pass, and one slot's 10 s walk
— and the enumerator started it around probe_one, which now began by
waiting up to five seconds for the node's register phase. A working receiver
whose phase another process released four seconds in could therefore trip
the budget before its slow slot reached its own timeout-and-cache fallback;
that is a Failed verdict, not Deferred, and two in a row retire the channel.

probe_one now takes the phase first and only then bounds the probe's I/O,
with every deadline of a pass in one ProbeDeadlines value (production
defaults unchanged) so the composition is one place to read and one value
for a test to shrink. The probes share a PassContext instead of threading
cache, clock, event sink and deadlines separately.

The Unifying probe keeps its phase to the end rather than dropping it after
the arrival drain: a slot whose feature walk exposes no marketing name reads
its codename from the receiver's 0xB5 register after the walk, and an error
reply to one 0xB5 sub-register is indistinguishable from another's. The
shared wait (8 s) allows for the walks running under it.

Composed tests with a scripted Bolt receiver whose paired mouse never
answers: a phase held 600 ms and released inside the wait, with drain plus
slot fallback that together with the wait outrun the budget, still settles
healthy on the cached slot; a phase held past the wait defers without a
byte of I/O; and I/O alone outrunning the budget is still a failed probe.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
A deferred probe preserved the node ledger but its empty outcomes still ran
through apply_outcomes and evict_unseen, so four deferred passes in a row
(CACHE_MISS_GRACE = 3) deleted the receiver's feature-cache entries — and,
for Bolt, persisted the deletion — while its last-good inventory and channel
stood and no device I/O had failed. The next real probe with a device
offline or its walk failing then had no capabilities or model data to fall
back on.

The enumerator now records the cache entries each node contributes, and a
deferred tick passes those to evict_unseen as frozen: neither seen nor
missed, counters standing until the node is actually probed again. Entries
of nodes that were checked or removed age exactly as before; an evicted
entry stops being any node's, and a vanished node's set goes with its
ledger. Regression test through NodeProbe::deferred, settle_probe,
apply_outcomes and evict_unseen, with a checked node aging alongside.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
…efore its first probe

A warm start loads the persisted Bolt entries before any receiver answers,
so a receiver whose register phase another process held from the first
pass had no record of what was its, froze nothing, and four such passes
deleted — and persisted the deletion of — the very capabilities deferral
exists to keep. A node deferred with no record now holds every entry no
node has claimed: nothing that was checked contributed them, so nothing
that was checked can have found them missing. Its first real probe
attributes what is its, and normal aging resumes from there.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
Give independent receiver replay scenarios process- and test-specific node identities so their host-wide register locks do not contend. Update the recording replay fixture to own its software-id lease.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
Keep cache ownership unknown until the first healthy probe. A failed initial probe must not create an empty ownership record that makes subsequent deferrals age persisted entries.

Cover failure followed by deferrals, preservation of the miss count and persisted cache, and eventual eviction after real failures resume.

Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
AprilNEA and others added 30 commits September 19, 2026 20:45
orchestrator/tests.rs was 1042 lines: 37 tests over six areas. Each is its own module now — device_list, device_settings, reapply, publication, camera, capture_plans — and tests.rs keeps the imports and the fixtures more than one area uses (the orchestrator and device builders, the inventories). Every child starts with `use super::*`.

Pure move: no test body changed; 37 tests before and after.
channel/tests.rs was 1191 lines: 30 tests over five areas of the channel. Each is its own module now — lease, send, raw_and_listeners, send_v20, same_header — and tests.rs keeps the imports and the fixtures more than one area uses (the mock channel helpers, the message builders, the pending-table probes); the lease recorder and the same-header constants moved with the only tests that use them. Every child starts with `use super::*`.

Pure move: no test body changed; 30 tests before and after.
…/tests.rs

host_switch.rs was 1041 lines, 400 of them an inline tests module. The module is host_switch/tests.rs now, beside the restore submodule that already lived there; `#[cfg(test)] mod tests;` stays where the inline module was.

Pure move: the module body is unchanged apart from one level of indentation; 21 tests before and after. host_switch.rs is 639 lines.
…rker.rs

runtime/button.rs was 1022 lines: the press, session and event types, the input handle and the owner, and below them the 320 lines the worker thread runs — draining commands and inputs, settling long presses, and emitting events. The worker is button/worker.rs now; the owner spawns run_worker as before, and the four worker steps the tests drive directly are imported back under cfg(test).

Pure move: no line inside the moved functions changed beyond pub(super) on what the owner and the tests call. button.rs is 702 lines.
…PI modules

inject/macos.rs was 1130 lines: key and click synthesis, media keys, scroll synthesis with its three quantisers, the Accessibility-tree browser navigation with its Safari check, three inline modules of dlopen'd private SPI, and an inline tests module. Six of those are files under inject/macos/ now — scroll.rs, browser.rs, app_services.rs, dock.rs, symbolic_hotkey.rs, tests.rs — and macos.rs keeps the execute dispatch and CGEvent key and click posting, declares the modules in one place, and re-exports the three entry points inject.rs calls. The FFI inventory in .agents/rules/objc-ffi.md lists the new files and watches the directory.

Pure move: the inline modules changed only their indentation; the scroll and browser code only its imports and the visibility of what macos.rs and inject.rs call (pub(in crate::inject) for the two re-exported entry points). Every moved code line exists verbatim in the parent. macos.rs is 515 lines.
…view.rs

features/mouse/view.rs was 1084 lines: the model view and its layout, the binding labels beside the render, the silhouette and hotspot elements, and an inline tests module. The labels — BindingLabel, LabelTrigger, label_control and binding_label_for_control, 220 lines — are view/labels.rs now, and the tests are view/tests.rs; the view calls the two label entry points as before.

Pure move: the tests changed only their indentation and gained the one import they used to reach through the view's; the label code changed only its imports and pub(super) on what the view reaches. view.rs is 703 lines.
…s.rs

features/camera/controls.rs was 1076 lines: the panel state and its device I/O, and below them 390 lines of free functions building what it renders — the profile chips, a slider or chip row per control, the frequency and binary rows, the reset button, and the label and unit helpers. Those are controls/rows.rs now; the panel's render calls the same entry points.

Pure move: the row code changed only its imports and pub(super) on what the panel calls. controls.rs is 688 lines.
Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
Co-authored-by: Xuan Zhang <xuan@arcbox.dev>
openlogi-desktop sits outside the rustdoc CI job, so its broken intra-doc links go unnoticed; `cargo doc -p openlogi-desktop --document-private-items` under -D warnings reported twelve. Two were left by yesterday's splits — [`key_points`] became ambiguous with the module of the same name, and the import pruning took away the LABEL_H a doc link relied on — and ten were older: renamed methods (AppState::current_device → current_record, battery_context → Self::status), a cfg-gated variant, a test function, a macro_rules! macro, and items no longer in scope.

Links now carry the path they need or become plain code spans where rustdoc cannot link (a variant built only off macOS, a macro_rules! macro, a test). The crate documents clean under -D warnings.
* chore: release v0.8.6

* chore(release): write whole-repo changelog

---------

Co-authored-by: aprilnea[bot] <263112868+aprilnea[bot]@users.noreply.github.com>
Co-authored-by: aprilnea[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: AprilNEA <github@sku.moe>
Co-authored-by: AprilNEA <github@sku.moe>
…#1456)

* feat(hid): recognise Nano receiver 046d:c534 (MK270, MK295)

The receiver-identity registry had no entry for USB product ID 0xc534,
the Nano receiver bundled with budget wireless combos such as the
MK270 and MK295, so OpenLogi never enumerated it or the paired
keyboard/mouse. Cross-checked against Solaar's NANO_RECEIVER_C534 as
the established reference implementation, alongside the neighboring
0xc537 Nano receiver already registered here.

Fixes AprilNEA#866

* test(hid): pin the new c534 receiver's exact identity mapping

receiver_identities_are_unique only proves no duplicate identity was
introduced; it says nothing about whether 0xc534 resolves to the right
brand and protocol. Adds a focused regression test alongside the existing
one for the c54d receiver.
* fix(agent): divert a single-bound gesture button

Turning gestures off leaves the gesture button on Single(MissionControl).
The capture plan compared that against the vestigial single-action
default, left the button native, and the firmware gives it no host
action, so the press did nothing while the GUI showed Mission Control.
Gesture sources now divert for any single action except None, the same
rule the haptic panel already used.

* docs(core): drop the stale native-capture note from set_gesture_mode
Co-authored-by: AprilNEA <github@sku.moe>
Co-authored-by: AprilNEA <github@sku.moe>
Co-authored-by: AprilNEA <github@sku.moe>
AprilNEA#1366)

* fix(gui): prefer firmware codename over catalog name for M650 variants

The asset catalog stores one static displayName per depot regardless of
which extended_model_id colour/hand variant a physical unit reports. A
plain Signature M650 (BLE direct) shares the Signature M650 L depot's
modelId (2b02a), so it always rendered as "Signature M650 L".

Add variant_display_name(): when the catalog name is exactly the
device's own reported name plus extra trailing qualifier word(s) (e.g.
"L"), prefer the device's own wording. A catalog name that isn't a
superset of the codename is left untouched, so the catalog stays
authoritative outside this variant-suffix case.

Fixes AprilNEA#1332

Co-authored-by: AprilNEA <github@sku.moe>

* fix(gui): only strip a recognized variant qualifier from the catalog name

variant_display_name treated any trailing catalog word(s) beyond the
codename's length as a wrong variant suffix to strip. That also
matches the opposite, legitimate case: a terse codename ("MX Master")
against a catalog name carrying a real generation suffix ("MX Master
3S") the codename doesn't repeat — stripping "3S" there loses a
correct, more specific model name.

Narrow the override to catalog names whose only trailing difference is
a recognized variant-qualifier word (currently "L"/"Left", the AprilNEA#1332
case), leaving a real model word like "3S" or "X" untouched.

Co-authored-by: AprilNEA <github@sku.moe>

* test(gui): cover per-device names with shared asset caching

Co-authored-by: AprilNEA <github@sku.moe>

---------

Co-authored-by: AprilNEA <github@sku.moe>
* chore: release v0.8.8

* chore(release): write whole-repo changelog

---------

Co-authored-by: aprilnea[bot] <263112868+aprilnea[bot]@users.noreply.github.com>
Co-authored-by: aprilnea[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Align each volume preset label with its existing forward/backward binding pair and add a regression test for that relationship.

Addresses the preset-label problem in AprilNEA#1414; the separate Linux uinput permission report remains open.
Keep the D-Bus executor alive while observer signal streams are dropped.

Fixes AprilNEA#1417

Co-authored-by: Sungyong Cho <46742040+sungyongcho@users.noreply.github.com>
Re-register an enabled agent service only when launchctl reports its job missing, while preserving disabled login-item settings.

Co-authored-by: raphael <raphael.jostan@tum.de>
Skip click-through overlays when selecting the pointer target and recognize Mission Control system overlays without relaxing dispatch guards.

Co-authored-by: pseudo <cdodge@pseudoseed.com>
* chore: release v0.8.9

* chore(release): write whole-repo changelog

---------

Co-authored-by: aprilnea[bot] <263112868+aprilnea[bot]@users.noreply.github.com>
Co-authored-by: aprilnea[bot] <41898282+github-actions[bot]@users.noreply.github.com>
…ecycle

Merge upstream master, retain pending restoration until a fresh keyboard channel, and forward genuine departure announcements through follower channels. Preserve physical inventory tracking and reject stale session completions.

Keep source-specific validation, exact control reporting modes, and regression coverage for retirement, reconnect cleanup, and bounded event-time slot reads.
Only active restoration writes block a waiting transition. Ready restoration tokens retain ownership and prevent their own keyboard from rearming without blocking another keyboard indefinitely. Add a replay-backed multi-keyboard regression that fails under the previous policy.
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.

8 participants