fix: only start one hotplug watcher, so connect fires once per plug - #36
Open
TKDubsta wants to merge 2 commits into
Open
fix: only start one hotplug watcher, so connect fires once per plug#36TKDubsta wants to merge 2 commits into
TKDubsta wants to merge 2 commits into
Conversation
start_watching checked whether a watcher was live and then stored a new one, releasing the lock in between. addEventListener starts addAttach and addDetach back-to-back without awaiting, so both ran at once, both saw an empty slot, and each spawned a watcher. Two watchers delivered every hotplug event twice, which surfaced as connect firing twice for a single plug-in. disconnect was unaffected because the JS side already dedupes it. Hold one lock across the check and the store, and take the napi methods from &mut self to &self: napi can poll them concurrently, so &mut self was handing out aliasing mutable references. They no longer need to be unsafe. Regression from the watch_task refactor in node-usb#18; v3.0.0 is unaffected.
There was a problem hiding this comment.
Pull request overview
This PR addresses a concurrency race in the Rust-side hotplug watcher management so that a single physical USB plug-in results in exactly one connect event, aligning behavior with node-usb 2.x expectations and preventing duplicate event delivery in consumers (e.g., Electron apps).
Changes:
- Makes
watch_taskshared/mutable viaArc<Mutex<Option<JoinHandle<()>>>>and holds a single lock across the “is watcher running?” check and storing the spawned task to prevent double-spawn. - Removes unnecessary
unsafe, switches N-API methods from&mut selfto&self, and makesstart_watching/stop_watchingsynchronous since they no longerawait.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix: only start one hotplug watcher, so
connectfires once per plugI ran into this moving an Electron app over from node-usb 2.x: a single plug-in was
triggering our USB alert rules twice.
connectfires twice for one physical plug on3.0.1.
start_watchingchecks whether a watcher is already running and then stores the newone, but lets go of the lock in between.
addEventListenerkicks offaddAttachandaddDetachback to back without awaiting either, so they run at the same time, bothsee an empty slot, and both spawn a watcher. Two watchers, so every hotplug event gets
delivered twice.
disconnectonly looks fine by accident — the TS side checksknownDevices.has(handle)and deletes the handle as it fires, so the duplicate gets swallowed there.
connecthasno equivalent check, which is why you get two connects but one disconnect.
Looks like it arrived with the
watch_taskrefactor in #18. 3.0.0 did this a differentway and isn't affected.
Reproducing it
You don't need a device for this — the race is on registration, not on the plug. Drop an
eprintln!("spawned")immediately before thetokio::spawninstart_watching, then:On
mainthat prints twice, every time — 50 out of 50 runs here, always from twodifferent threads. With the patch it prints once, 50 out of 50.
On actual hardware, replugging a Brother PT-P950NW on 3.0.1 gave 1 disconnect and 2
connects (same tick, same serial), against 1 and 1 on node-usb 2.18.0.
The fix
Hold one lock across both the check and the store, which means putting
watch_taskbehind an
Arc<Mutex<..>>the waycallbacksalready is.Ive also taken the four napi methods from
&mut selfto&self. napi can poll themconcurrently, so
&mut selfwas handing out aliasing&mut— the duplicate watcher isreally just the visible symptom of that. They don't need to be
unsafeany more either,which clears four clippy warnings. Neither
start_watchingnorstop_watchinghas anawait in it now, so both drop
async.I checked it doesn't over-correct: remove both listeners and re-register, and you get
exactly one fresh watcher, so teardown still works. Generated
index.d.tsis unchanged,and fmt, build and tsc all pass.
Things you might want done differently
This could be fixed in the TS instead, by chaining the two native calls so they can't
overlap — probably a two-line change. I went the Rust route because the gap is open to
any concurrent caller rather than just that one call site, and the
&mut selfseemedworth getting rid of regardless. Happy to redo it the other way if you'd rather.
The lock is now held across
nusb::watch_devices(). It's only contended while listenersare being set up, so I don't think it matters in practice, but if you'd sooner not hold
it there, moving the stream creation inside the spawned task would narrow it to just the
store.
Only built and run on macOS arm64.