Bus ids are positional: after a server restart or a replug the same
busid can carry an arbitrary other device, and a worker retries its
last busid every few seconds — it would import (and expose to the local
USB stack) whatever now sits on that port, e.g. an input device in
place of the intended one. OP_REP_IMPORT already carries the identity,
so each attach now checks the reply busid round-trips and the
vendor/product (and serial where available, with the control snapshot
as fallback) still match the rule the worker serves.
The desired set (assignment lock) and the worker map (workerAccess)
were updated in separate critical sections, so a concurrent resync
could observe one but not the other: the same busid ended up with two
workers (leaking the first cancel), and stop decisions ran against
half-applied state. allWorkers is now the single source of truth,
diffed, started, and stopped in one workerAccess section; workers
remove themselves on exit with an identity check and restart in place
if their busid became desired again meanwhile.
The per-busid active flag also became a count: during matched-mode
handoff two workers briefly reference one busid, and a failing worker's
deactivation must not erase the mark of the genuinely attached one
(which made ApplyAll detach live devices, oscillating).
Against a plain usbipd (no control extension) the client synced the
device list exactly once and then slept until shutdown: devices that
changed busid on replug were never rediscovered, and in all-devices
mode a worker that stopped had no path back. Refresh the devlist on an
interval, re-probe the control extension every few minutes in case the
server was upgraded, and warn that serial rules cannot match against
servers whose listings carry no serial numbers.
The install fast path treated an openable VHCI interface as 'driver
present', but the interface GUID is identical across all usbip-win2
releases while PLUGIN_HARDWARE_ONCE and STOP_ATTACH_ATTEMPTS only exist
since 0.9.7.5 — a machine with an older community release passed the
check and then failed every Plugin forever. The fast path now probes
STOP_ATTACH_ATTEMPTS and falls through to the (in-place, FORCE) install
when the driver lacks it. The probe's empty location doubles as
cleanup, discarding ghost reattach attempts left by a previous process.
PLUGIN_HARDWARE_ONCE only suppresses retries of the initial attach;
when an established connection later drops, wsk_receive.cpp
unconditionally schedules background reattach attempts (~20 tries over
~2 hours) toward this session's dead one-shot loopback port. With
ephemeral port reuse those ghost connects steal a later session's
Accept and fail its Plugin. Wire up STOP_ATTACH_ATTEMPTS (0x805,
present in both bundled driver builds) and cancel the session's exact
host/service/busid location before plugout.
The one-shot loopback listener accepted the first connection with no
validation, and the handshake discarded the busid the peer sent. Any
local process racing the driver's WSK connect could receive the
OP_REP_IMPORT device info and speak raw USB/IP to the remote device
while denying the real attach. Accepted peers must now be kernel-owned
sockets (GetExtendedTcpTable owner is the System process), complete the
handshake within a deadline, and request exactly this session's busid;
rejected peers are dropped without ending the session.
When the exported device was replaced underneath usbip-host (devnum
changed after a flap) or the recorded original driver was empty,
releaseExport skipped the re-bind and left the device driverless — and
the USB core never re-probes such devices, so it stayed dead until a
physical replug. Worse, the next Reconcile recorded originalDriver=""
for it, making every later release repeat the damage. Release now falls
back to usbip-host's rebind attribute (device_attach, the official
usbip unbind path) with drivers_probe as a second fallback.
The raw blocking fd had three lifecycle bugs: close() does not wake a
thread blocked in recvfrom, the watchdog and the error path both closed
the fd without coordination, and a closed fd number could be recycled
to an unrelated connection that the lingering reader would then consume
from and the second close would tear down. Wrapping the nonblocking fd
in os.File gives poller-based reads that Close wakes, idempotent close,
and no reuse while a read is in flight.
BroadcastIfChanged computed the snapshot under inventoryAccess and
published it under broadcastAccess as two separate steps, so two racing
broadcasts could publish in the opposite order they computed, locking a
stale snapshot in as the authoritative state seeded to new subscribers.
Compute, publish, and subscriber enqueue now happen in one
broadcastAccess critical section (enqueue is a non-blocking send).
Reconcile snapshots the export table, works on the copy unlocked, and
commits it wholesale; FinishImport ran outside reconcileAccess and its
table updates could be overwritten by the stale commit. On darwin that
leaked the re-captured IOUSBHostDevice handle (device unusable until
process exit) and left an export whose handle was closed. Both call
sites now take reconcileAccess.
A submit marked started has a window before its goroutine reaches
engine.Submit. CMD_UNLINK arriving in that window aborted an empty
endpoint and then blocked the serve loop forever on the drain channel —
the submit entered the engine afterwards with nothing left to cancel it
(engines only time out EP0), wedging the session and every Close above
it. Linux vhci clients send exactly this sequence on URB timeout.
Submits now pass an enterSubmit gate that hands off atomically with
unlink: marked-first unlinks make the goroutine skip the engine, and
entered submits are aborted with periodic re-aborts to cover the abort
racing ahead of the URB inside the driver. Session teardown drains with
the same re-abort loop instead of a single pass.
The catalog signature covers the INF bytes; git's CRLF-to-LF
normalization made SetupCopyOEMInfW reject the package with
ERROR_FILE_HASH_NOT_IN_CATALOG, so the drivers could never install.
Restore the pristine upstream bytes and mark both driver asset trees
-text so checkout cannot corrupt them again.
VBoxUSBMon only rewrites a device's IDs while PnP enumerates it, so
adding a filter for an already-plugged device captured nothing until a
physical replug, and releasing one left it dead under VBoxUSB.sys.
Capture and release now drive the cfgmgr32 restart sequence
(query-and-remove, hub port cycle, re-setup) around the filter change,
mirroring usbipd-win's RestartingDevice.
Capture also changes the devnode's identity to the VBox stub ID, which
broke everything keyed on it: enumeration now reads the true
vendor/product/class/speed from the parent hub's descriptor cache (the
registry hardware ID reads as 80EE:CAFE once captured), data sessions
locate the VBoxUSB interface by bus/address instead of the original
instance ID, and Reconcile keeps exports alive through the
re-enumeration window with a short absence grace.
Device and Monitor shared one manual-reset event across all in-flight
IOCTLs, while the session layer runs one goroutine per endpoint: the
first completion released every GetOverlappedResult waiter with the
first operation's byte count, returning before the driver finished
writing the other buffers. Each overlappedIoctl call now owns its
event, making concurrent URBs (and aborts) on one handle safe.
USBFILTERTYPE has CAPTURE = 4; the encoded value 5 is END, the enum's
out-of-range sentinel, which USBFilterValidate rejects with a negative
rc — so ADD_FILTER never installed a filter and no device could be
captured.
Every SUPUSB/SUPUSBFLT CTL_CODE was computed with Access = 0 instead of
FILE_WRITE_ACCESS (2 << 14), so the driver dispatch rejected each call
with STATUS_INVALID_DEVICE_REQUEST and the export host could not even
pass monitor GET_VERSION. Values now match usbipd-win's interop
definitions (VBoxUsb.cs / VBoxUsbMon.cs).
Implement the Windows importer over vadimgrn/usbip-win2's UDE (USB Device
Emulation) driver. The driver does the TCP connect and import handshake
in-kernel via WSK, so it cannot take sing-box's already-dialed (and
possibly proxied) socket. Instead each Attach runs a one-shot loopback
listener, points the driver at it with PLUGIN_HARDWARE_ONCE, answers the
driver's in-kernel OP_REQ_IMPORT from the cached device info, and splices
the loopback stream to the proxied server connection -- so all dialer and
proxy behavior stays in userspace.
The Microsoft-signed driver is bundled and auto-installed like
common/vboxusb: amd64 from usbip-win2 0.9.7.7, arm64 from 0.9.7.5 (the
newest release with an arm64 build; identical ABI). EnsureDriver extracts
the package, registers the upper-filter, and creates the root-enumerated
UDE devnode via SetupAPI.
Also fix the control-channel fallback to treat a standard server's
connection reset (ECONNRESET on Windows) the same as a clean EOF, so the
client falls back to standard usbip discovery instead of retrying forever.
Verified on Win11 x64: self-installs the driver on a clean machine and
imports a device; behaves byte-identically to the official usbip-win2
client.
Windows export now reports the real USB link speed, probed from the
parent hub (IOCTL_USB_GET_NODE_CONNECTION_INFORMATION_EX and _V2 for
SuperSpeedPlus), so SuperSpeed devices route to the correct root-hub
speed domain instead of advertising speed=0.
- protocol: pin DeviceInfoTruncated/DeviceInterface wire sizes with
two-sided compile-time assertions so a struct change fails the build
instead of silently mis-bounding the reader
- server: bound inbound connections with a handshake read deadline and a
per-iteration idle deadline on the control loop, plus write deadlines
on control writes; clear the deadline before the conn becomes a data
session
- server: serialize import reservation under reconcileAccess so a reserve
cannot interleave a reconcile pass that would release a busy device
- data: validate CMD_SUBMIT iso descriptor offset/length against the
transfer buffer before forwarding to a platform engine
- darwin: make darwinUSBHostDevice.Close idempotent via sync.Once to
avoid a double close/free under concurrent shutdown
- windows: guard windowsExport.device with a mutex and hand the claimed
handle to a single closer
The control extension carried device-state updates in two wire forms: a
full snapshot on subscribe and Added/Updated/Removed deltas thereafter.
At realistic USB device counts the delta path saves no measurable
bandwidth and matches the snapshot's hotplug latency on the same socket,
so the bookkeeping (sequence field on every frame, lastSeq+1 jump check,
Subscribe sequence-stability retry loop, applyControlDelta) was pure
ceremony. Every broadcast now emits controlFrameDeviceSnapshot with the
full device list; clients overwrite their remote map from each snapshot.
controlFrame loses its Sequence field, shrinking the wire header from
12 to 4 bytes. BroadcastIfChanged uses maps.EqualFunc with the existing
deviceInfoV2Equal to skip no-op broadcasts.
The secondary index threaded from sysfs status* files into
linuxClientSession only decorated one debug log string. Port numbers
are already globally unique across vhci controllers, so the port
alone identifies the attachment. Drop the field, the per-attach
sysfs glob+read for lookup, and the now-redundant filename parser;
the inline status* filter in readPrimaryVHCIStatus is enough to
guard the multi-file enumeration that vhciPickFreePort still needs.
Both peers always advertise the full capability set, so the
extended==false code paths are unreachable. Stock usbipd peers fail
the SBUSBIP1 preface entirely and fall through to runStandardStaticMode
via errControlUnsupported, so removing this mode loses no observable
feature.
Also shrinks the control wire frame from 16 to 12 bytes by dropping
the now-meaningless Capabilities field, and deletes the lossy V1-to-V2
syncRemoteStateAndResetControlState helper in favor of clearing the
remote cache and reconnecting on delta sequence jumps.
The plain OP_REQ_IMPORT + TryReserveForImport path already delivers
race-free exclusive access for every observable feature; the lease
two-phase commit (OP_REQ_IMPORT_EXT, control lease frames,
serverImportLease) only added identity-replacement detection and
pre-import busy advertisement, neither of which is asserted by any
feature test.
TryReserveForImport now runs as a single critical section under
inventoryAccess, closing the TOCTOU window the LeaseIdentity recheck
used to guard. Export interface loses LeaseIdentity/LeaseCheck.
controlPingLoop writes directly via writeControlMessage, dropping the
clientControlSession holder and the controlSession/controlAccess fields
on ClientService. Control protocol drops the ImportLease capability
bit; controlExtensionCapabilities is now PayloadFrames | DeviceStateV2.
host_windows.go installs the VBoxUSB drivers on Start, opens the
\\.\VBoxUSBMon handle, reconciles configured matches by enumerating
USB devices and adding per-(vid,pid) CAPTURE filters. engine_windows.go
implements URBEngine on top of vboxusb.Device: EP0 SET_CONFIGURATION /
SET_INTERFACE / CLEAR_FEATURE(ENDPOINT_HALT) are trapped onto their
dedicated IOCTLs, control transfers are wrapped as USBSUP_TRANSFER_TYPE_MSG
with the 8-byte setup prefix, bulk/interrupt go through SEND_URB, iso
transfers up to 8 packets work in one shot.
Known limitations carried forward as TODOs:
- vboxusb.RestartDevice and WatchDeviceArrival are still stubs, so
capture only triggers on the next physical PnP arrival after the
filter is installed; Events ticks on a 2 s timer instead of using
CM_Register_Notification.
- Iso transfers with > 8 packets and the abort-holdoff heuristic are
not yet implemented; URBs that exceed the per-IOCTL packet limit
fail with a descriptive error.
- usbip-client on windows returns "not yet implemented" from
newPlatformImportHost (needs a virtual host controller driver).
Widen the //go:build constraints on every shared usbip file from
"linux || (darwin && cgo)" to also include "windows", narrow
register_stub.go to exclude windows, and add backendIDWindowsVBoxUSB
so the upcoming Windows export host can stamp its snapshots.
No behavior change on darwin/linux: the existing platform-specific
files keep their narrower tags, and the windows build still lacks the
newPlatform{Export,Import}Host functions until the next commit.
The VBoxUSB.sys / VBoxUSBMon.sys / .inf / .cat binaries copied from
dorssel/usbipd-win Drivers/{x64,arm64}/ ship paired SPDX
*.license files that record Oracle's GPL-3.0-only copyright. Carrying
them next to the assets keeps the provenance discoverable.
Both the existing darwin backend and the upcoming windows backend
drive USB devices from user space (IOUSBHost CGO calls vs. VBoxUSB
IOCTLs). Refactor the per-attachment URB loop out of host_darwin.go
into a platform-agnostic userspaceURBSession that talks to a URBEngine
interface; the darwin-specific dispatch becomes a 30-line
darwinIOUSBHostEngine. Linux's kernelHandoffSession is untouched.
Move hex8 into shared.go and add usbipStatusEIO so the shared session
does not depend on golang.org/x/sys/unix (Windows has no equivalent).
The server's pendingConnsWG/sessionsWG and the client's wg + 15s shutdown
timer only delayed Close until goroutines settled; they did not own any
resource release. closeConnOnContextDone already closes each accepted or
dialed conn on ctx cancel, so reads/writes error out and the goroutines
exit on their own. Cancel ctx, close the listener and sessions, call
host.Close, return -- no Wait. Keep darwinServerDataSession.wg: it gates
in-flight cgo submit goroutines against device close and is a correctness
barrier, not shutdown defense.
Tie accepted conns to s.ctx via closeConnOnContextDone and wait for
dispatch goroutines in Close so handshake reads/writes can no longer
leak goroutines or fds past service shutdown.
Export.{Snapshot,LeaseCheck,DeviceInfo}, ExportHost.{Reconcile,FinishImport},
ImportHost.Start, and all 11 exportLedger methods carried ctx params that
implementations never consumed (linux FinishImport now reaches into h.runCtx
internally). UrbTransaction.{Wait,Cancel} did consume ctx, but every call site
passed context.Background(), forcing the reverse pattern in endpoint_darwin
where e.ctx was already cancelled. Cancel becomes synchronous, Wait reads
under the close(t.done) happens-before. ExportHost's runCtx now derives in
newPlatformExportHost so Start() is just precondition-check (linux
ensureKernelPath / darwin no-op), and the three pre-Start nil defences in
Close/Events fall out.
Replace E.New(fmt.Sprintf("...0x%04x", v)) with variadic E.New(..., fmt.Sprintf("0x%04x", v)) so plain text and integer args flow through F.ToString (sing-box/common/stun/stun.go precedent). Hoist three sysfs_linux.go if-init error checks per .claude/rules/go-syntax.md ("assign first, then check"); two of them also shadowed the outer err.
- Delete docs/adr/0001 and every code reference; the immutability contract
is now a four-line comment on the Export interface where it applies.
- Inline applyStaleClones at the linux/darwin reconcile call sites and
drop the helper plus the parallel staleBusIDs/pendingByBusID maps the
darwin host built only to match its signature.
- Inline single-use sentinel errors in iso_scheduler.go and
endpoint_darwin.go (no errors.Is callers).
- Rename exportLedger.fast/inventory to broadcastAccess/inventoryAccess
to match the Access suffix used by every other lock in the package.
- Strip narrative doc comments on exportLedger, the with* helpers,
IssueLease, ConsumeLeaseAndReserve, TryReserveForImport, host.go
interfaces, cgoCallbackHandle, and cloneDarwinExport; keep only the
non-obvious WHY required by .claude/rules/code-comment.md.
Net: 8 files, 99 insertions / 291 deletions, no behaviour change.
ExportHost.Events spawned goroutines tied to a caller-supplied ctx, and
neither host.Close nor the per-error cleanup in ServerService.Start
cancelled that ctx. A bind failure on port 3240 after Events succeeded
leaked ueventLoop, the netlink fd, and the darwin watcher goroutine.
The host now owns its background lifecycle: Start derives runCtx from
the caller's ctx, Close cancels it, Events binds goroutines to it.
ServerService.Start switches to a deferred cleanup that cancels and
closes the host on any error, so future failure steps cannot forget it.
The accepted limit of 4096 was looser than Linux's hard cap of 1024 in
drivers/usb/usbip/usbip_common.h, so a real peer would reject submits we
considered valid. Tighten the validator to 1024 to match upstream before
multi-packet iso lands.
P2: Darwin IN iso responses accepted malformed RET_SUBMIT descriptors —
aggregate ActualLength was checked but per-descriptor Offset/Length were
not, and ScatterIsoResponse silently clamped bad values into apparent
success. ValidateIsoResponse now lives in iso_scheduler.go beside
EncodeIsoSubmit/RebaseFrame and enforces the single-packet shape (Offset
== 0, Length == requestLen, ActualLength <= Length, sum == header,
payload covers range). pendingTransfer.validateResponse routes every
RET_SUBMIT shape check through one named seam so future defects land in
one place, and ScatterIsoResponse drops its defensive clamps so the
data-movement primitive can no longer mask a validation gap.
P2: kernelHandoffSession.Close mutated h.conn/h.monitorFile/h.relayConn
outside stateAccess while Start reads them under that lock; the server
registers prepared sessions before Start, so Close-before-Start can race
the direct-TCP path during import-time shutdown. Close now snapshots and
nils the three fields under stateAccess before closing the locals via
closeOnce, restoring symmetry with Start's under-lock reads and matching
the copy-under-lock, close-outside idiom established by
exportLedger.CloseAllSubscribers and ServerService.Close.
P1: The IOUSBHostControllerInterface command/doorbell blocks ran on an
Apple-owned dispatch queue, so callbacks could still fire (and panic in
cgo.Handle.Value) after Close deleted the handle. The controller now owns
a serial dispatch queue and drains it via dispatch_sync before tearing
the wrapper down — the watcher's pattern, extracted into a shared
box_usbhost_drain_and_release_queue helper.
P2: scatterResponse only bounds-checked ActualLength on IN transfers, so
an OUT RET_SUBMIT with a negative or oversized actual_length flowed
straight to box_usbhost_endpoint_sm_complete as a wrapped C.size_t.
Validation moves into pendingTransfer.accept, which reconciles every
response against the request before the length leaves the package.
Also wraps cgo.Handle in cgoCallbackHandle so destroy-then-Delete
ordering is encoded structurally for both the watcher and the controller.
- Route every reserved-state mutation through withInventoryWrite; the
lease insert path now broadcasts so extended subscribers see the busy
transition immediately instead of waiting for the next mutation. Folds
cleanupExpiredLocked changes into the broadcast decision on every
IssueLease early-reject path. Rename the field to inventory and force
read/write/write-quiet sites through dedicated accessors so future
callers cannot bypass the invariant.
- Mirror the linux clone-then-swap pattern in darwinExportHost.Reconcile
via cloneDarwinExport, so the ledger's unlocked Snapshot / LeaseCheck
reads never observe a half-mutated stale flag. Documented as
docs/adr/0001-export-pointer-immutability.md and on the Export
interface; both hosts now share the applyStaleClones helper.
- Rewrite 27 if (_, )?err := …; err != nil sites to assign-then-check
per .claude/rules/go-syntax.md.
- Delete parse / builder / tautological tests forbidden by
.claude/rules/code-test.md (option/usbip_test.go,
iso_scheduler_test.go, usbhost_darwin_status_test.go).
- Tag nine more usbip files with linux || (darwin && cgo); fixes
pre-existing windows / android lint failures because the protocol
types were only consumed by tagged code.
control_protocol.go was ungated but referenced ExportLeaseIdentity
from host.go (linux || (darwin && cgo)), breaking the stub-register
contract on Windows and darwin && !cgo. Move serverImportLease and
importLeaseTTL into export_ledger.go alongside their only consumers.
Drop the constructor guard so client import-all (Devices empty/omitted)
actually works, route ledger lease mutations through a new
mutateAndBroadcast helper so Unsubscribe-triggered release reaches all
subscribers, and tighten the Events contract: subscribe at Start so a
host failure aborts service start instead of silently disabling hotplug.
Three review findings shared one shape: a contract documented in
comments but reimplemented at each call site. Each fix collapses
the contract into a single named home.
- RebaseFrame now rounds up to the least frame >= currentFrame
with the matching low 8 bits, so scheduled iso submits never
land in the past. The .m bridge trusts the asap flag instead
of treating start_frame=0 as ASAP, which also fixes a latent
int32-wraparound path.
- NewServerService and NewClientService reject empty devices
with a clear error instead of starting up exporting nothing.
- exportLedger.reservedLocked unifies busy + outstanding lease;
AvailableExports and snapshotDeviceState consult it so legacy
devlist and control snapshots no longer advertise a leased
busid as available. IsBusy renamed to IsReserved across the
ExportHost interface and call sites.
Two supporting deepenings:
- Drop FrameOracle/IsoScheduler; iso submit is now the free
EncodeIsoSubmit(currentFrame, ...). The darwin endpoint holds
currentFrame func() uint64.
- Extract SelectMatches; host_linux and host_darwin Reconcile
loops share the match/dedup step.
The peer now owns submit/cancel lifecycle: pendingSubmit retains the
original DevID per submit, CMD_UNLINK carries it (stub_rx valid_request
was silently rejecting devid=0), and RET_UNLINK finalizes the bound
transaction. UrbTransaction.Cancel delegates instead of assembling the
wire itself. Stale exports stay in linux and darwin host snapshots so
the ledger broadcasts unavailable State updates rather than Removed.
Extract wire, URB lifecycle, iso translation, and per-endpoint behaviour
from client_darwin.go into dedicated modules so EndpointPause/Destroy can
quiesce in-flight URBs via CMD_UNLINK and so iso frame numbers survive the
256-frame wrap.
- usbip_peer.go: owns seqnum, write serialization, pending map, and
RET_SUBMIT/RET_UNLINK dispatch.
- urb_transaction.go: deterministic state machine for one in-flight URB
with Cancel + Wait; late RET_SUBMIT after cancel is swallowed.
- iso_scheduler.go: rebases Apple's 8-bit CI frame against the
controller's monotonic counter; explicit ASAP encoding into
TransferFlags so the server can pass firstFrameNumber=0 deliberately
rather than relying on the start_frame=0 sentinel.
- endpoint_darwin.go: per-endpoint worker selects on cmd/doorbell/
transaction-done; Pause/Destroy cancels the pending transaction,
completes the Apple CI transfer with ciStatusOffline, then writes
the CI success response. client_darwin.go becomes a thin demuxer.
- usbhost_darwin.{m,h,go}, host_darwin.go: plumb the wire-level ASAP
flag through to box_usbhost_device_iso so server-side scheduling
distinguishes "scheduled at frame 0" from ASAP.
Three independent correctness bugs that all violated "decide before
publishing": each surface (wire reply, sysfs path, broadcast state)
committed to a state the code later had to contradict.
darwin: switch IOUSBHostPipe abort to IOUSBHostAbortOptionSynchronous and
add the EP-0 path via abortDeviceRequestsWithOption (previously a silent
no-op for control transfers). After abort returns, await a per-pending
drained channel before writing RET_UNLINK so the late RetSubmit
suppression in finishSubmit has actually taken effect — the drained
channel is allocated lazily by markSubmitUnlinked, so the hot submit
path is unchanged.
linux: collapse the per-controller VHCI abstraction. The kernel exposes
attach/detach/status/status.N only on vhci_hcd.0 with globally unique
port numbers; the previous code wrote vhci_hcd.N/attach for N>0 and
silently failed past the primary controller's port range. Glob status*
on the primary, key reservations on bare port int, and carry the
status-suffix as a diagnostic-only secondary index for Description().
export_ledger: ConsumeLeaseAndReserve no longer publishes busy=true and
then conditionally rolls it back. Read seq under fast first, then make
every decision (including the lease.Generation check) inside one slow
critical section before any busy mutation. This removes the window
where a concurrent BroadcastIfChanged could observe transient busy and
poison l.state with no corrective broadcast on rollback.
Address `golangci-lint` modernize and unused warnings:
- replace map copy loops with `maps.Copy`
- collapse `if x > y { x = y }` clamps to `min`/`max`
- switch `for i := 0; i < n; i++` to `for i := range n`
- use `t.Context()` in the relay handoff test
- delete the unused `removeOnce` field and `openBinaryDevice` helper