Repository navigation
fix(macos): give USB hotplug detection a working macOS/Linux backend - #150
Conversation
ConnectionManager built a WMI ManagementEventWatcher directly in its constructor. WMI is Windows-only, so on macOS, iOS and Android the constructor threw PlatformNotSupportedException into a catch that only logged. USB hotplug-removal detection was therefore dead on macOS with no user-visible signal: unplug a connected device and the app keeps showing it as connected until you disconnect it by hand. Route ConnectionManager through the Services/DeviceWatcher abstraction that already existed for this and was never wired up, and add the POSIX-desktop backend it always anticipated: - SerialPortPollingDeviceWatcher samples SerialPort.GetPortNames() once a second and raises DeviceRemoved when the port set shrinks. It reads the same source CheckIfSerialDeviceWasRemoved consumes, so it cannot report a removal the handler then fails to see. Only shrinkage fires; an enumeration failure leaves the previous snapshot in place rather than looking like every device was unplugged at once. - DeviceWatcherFactory picks WMI on Windows, the poller on macOS/Linux, and NoOpDeviceWatcher on the mobile heads, which have no serial transport to watch. The startup exception (and the Fatal Sentry breadcrumb it put at the head of every Android crash report) is gone. - The catch stays, because ConnectionManager is a static singleton and an escaping throw would be a fatal TypeInitializationException. What changes is that it no longer swallows: a watcher that fails to start on a platform that has USB serial sets HotplugDetectionUnavailableMessage, and DaqifiViewModel raises a standing notification telling the user what they will see. Also drops the five CA1416 platform-compatibility warnings the WMI call sites carried, which is what the analyzer was pointing at all along. closes #90 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoFix cross-platform USB hotplug removal detection
AI Description
Diagram
High-Level Assessment
Files changed (5)
|
Code Review by Qodo
1.
|
All three findings were real; none were rejected. 1. (High) The removal callback touched UI state off-thread. Every backend raises it from a thread-pool thread — WMI's EventArrived as much as the new timer — and `NotifyConnection = false` runs DaqifiViewModel.UpdateUi synchronously, which mutates the UI-bound notification collection. Off the UI thread that can throw back into the watcher, which would swallow the removal before the worker was even started. The reset now goes through Dispatcher.UIThread.Post (Post, not Invoke, so it stays ordered ahead of the notification raised later and cannot block a host with no dispatcher pumping). ConnectedDevices is also snapshotted on the UI thread rather than enumerated from the worker: it is a plain List mutated by Connect/Disconnect, and a concurrent connect would throw "collection was modified" into BackgroundWorker's swallowing catch and lose the removal. Port enumeration, the slow part, stays off-thread. Latent on Windows already; this PR is what makes it reachable on macOS/Linux, so it belongs here. 2. (Medium) A backend whose Start() threw was retained despite the comment promising an inert fallback — and WmiDeviceWatcher assigns its ManagementEventWatcher and hooks its event before the call that can throw, so that leaked a live subscription for the process lifetime. The failed watcher is now disposed and NoOpDeviceWatcher installed. The disposal's catch is empty on purpose: this is a static field initializer, where an escaping cleanup failure would be a fatal TypeInitializationException. 3. (Medium) A poll still enumerating across a Stop()/Start() could compare its stale reading against the new baseline and report a removal that never happened. Each timer callback now carries the generation it was created for and retires when that generation is bumped. Stop still does not block on in-flight callbacks — that could deadlock a Stop called from the UI thread against a handler that dispatches to it — so the generation is re-read before raising, which closes all of the window this method controls. Bench re-run after the changes, board attached: both harnesses ALL CHECKS PASSED, including three new lifecycle checks (a stopped watcher raises nothing; a restart re-baselines instead of reporting what it missed; a shrink after restart is still detected). Desktop 292 warnings, iOS 1248, both clean rebuilds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit fba72b6 |
Qodo round 2, one new finding, valid. The Dispatcher.UIThread.Post added in the previous commit runs on the watcher's own thread, and Post can throw once the dispatcher is going away. On Windows that thread belongs to WMI, which invokes subscribers with no protective catch, so an escaping exception would abort the removal before the worker that performs it is even created. Routed through a PostToUiThread helper that runs inline when already on the UI thread and swallows a shutting-down dispatcher otherwise — the same shape and reasoning as BootloaderWatcher's existing UI marshal, which the repo already settled on for exactly this case. If the dispatcher is gone the update is dropped, which is the right outcome at shutdown. Build gate re-run clean: desktop 292, iOS 1248. Both bench harnesses still ALL CHECKS PASSED with the board attached. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 19db6ab |
|
/agentic_review |
|
Qodo-clean, CI green — ready for review. (Re-validated on |
Not merging — for review.
closes #90What was wrong
Unplug a connected DAQiFi device on macOS and the app never noticed. It kept the device in the connected list, kept showing it as connected, and left the user to work out that the only way back was to press Disconnect on a device that was already gone.
The cause:
ConnectionManager's constructor built a WMIManagementEventWatcherdirectly. WMI is Windows-only, so on macOS the constructor threwPlatformNotSupportedExceptioninto acatchthat only wrote a log line. Hotplug-removal detection was dead from the first moment of every launch, and nothing said so. The same throw fires on iOS and Android — on Android the Sentry SDK recorded it as aFatalbreadcrumb, so it headed the breadcrumb trail of every crash report before the user had touched anything.Reproduced on this Mac verbatim, from the removed two lines:
What was fixed
Services/DeviceWatcher/already had the abstraction for this —IDeviceWatcher,WmiDeviceWatcher,NoOpDeviceWatcher,DeviceWatcherFactory— but nothing was ever routed through it;ConnectionManagerstill constructed WMI directly. That is now wired up, and the missing POSIX-desktop backend is added.I chose the "give macOS a working hotplug path" option, not the "make the degradation visible" fallback, because on macOS the trigger is cheap and exact:
SerialPort.GetPortNames()is already the listCheckIfSerialDeviceWasRemovedcompares against, so a watcher that samples it can never report a removal the handler then fails to see. There was no reason to settle for telling the user it doesn't work.SerialPortPollingDeviceWatcher(new) — samplesSerialPort.GetPortNames()once a second and raisesDeviceRemovedwhen the port set shrinks. Only shrinkage fires it: arrivals belong to discovery, and the Windows backend likewise subscribes to removal (EventType = 3) only. An enumeration failure returns null and leaves the previous snapshot in place, so a transient/devproblem can never look like every device being unplugged at once; those failures are logged once per outage, not once per second. Exceptions cannot escape the timer callback (that would terminate the process mid-acquisition), and each callback carries theStart/Stopgeneration it belongs to, so a tick still enumerating across a restart cannot compare its stale reading against the new baseline.DeviceWatcherFactory— WMI on Windows, the poller on macOS/Linux,NoOpDeviceWatcheron the mobile heads, which have no serial transport to watch. The startup exception is gone on all three affected heads, and with it theFatalbreadcrumb on Android.ConnectionManager— thecatchstays, and stays load-bearing: this runs from a static field initializer, so an escaping throw would be a fatalTypeInitializationExceptionat first access ofInstance. Backend selection now happens inside it for the same reason, and a backend whoseStart()throws is disposed rather than retained (WmiDeviceWatcherhooks its event before the call that can throw). What changed is that it no longer swallows. It logs which backend was chosen (USB hotplug detection backend: …— the first thing to check on a "unplugging did nothing" report), and a watcher that fails to start on a platform that has USB serial setsHotplugDetectionUnavailableMessage.DaqifiViewModel— turns that message into a standing notification, so the residual failure case is visible in the UI instead of log-only. It says what the user will see ("the app will keep showing it as connected until you disconnect it yourself"), not which watcher failed. Nothing is raised on the mobile heads: no serial hardware means nothing to detect, so their no-op backend is by design, not a degradation.One pre-existing bug came along, because this PR is what makes it reachable.
CheckIfSerialDeviceWasRemovedranNotifyConnection = falseon the watcher's thread, and that notification runsDaqifiViewModel.UpdateUisynchronously, which mutates the UI-bound notification collection. Off the UI thread it can throw straight back into the watcher — losing the removal before the worker that performs it is even started. It was already latent on Windows (WMI'sEventArrivedis a thread-pool thread too), but until now it was unreachable on macOS/Linux because the watcher never ran there. The reset now goes through a shutdown-tolerant UI post (same shape asBootloaderWatcher's existing marshal), andConnectedDevices— a plainListmutated byConnect/Disconnect— is snapshotted on the UI thread instead of enumerated from the worker. Port enumeration, the slow part, stays off-thread.Removing the WMI call sites also drops the five
CA1416platform-compatibility warnings they carried (desktop 297 → 292). The analyzer had been pointing at this bug the whole time.Guarding tests
There were none, and there is no test project in this repo — that absence is part of why this shipped. Verification was done by hand at the bench with two throwaway harnesses driving the app's real code path; the checks they encode are written out below so they can be re-run or promoted if a test project ever lands.
Verification — at the bench, macOS 15 (Darwin 25.5.0), Nq1 on
/dev/cu.usbmodem1101, fw 3.7.2, S/N 9090539562006014104Build gate (clean rebuilds,
--no-incremental, re-measured against current main after #149 landed — both heads built the same way in the same worktree, no remembered numbers): desktop 292 warnings vs main's 297, iOS Releaseiossimulator-arm641248 vs main's 1253. −5 on each head: the fiveCA1416warnings the removed WMI call sites carried. Nopackages.lock.jsonchanged. The Android head does not build on this Mac (workload absent) — CI covers it and is green; I did not run it locally.The real app, launched on macOS. The log line at the exact call site that used to carry the exception now reads:
No
PlatformNotSupportedException, no errors or warnings anywhere in the log. Idle at ~1% CPU over a multi-minute soak with the 1 Hz poll running; the log did not grow.Watcher behaviour, driven against this Mac's real port table (8 ports; the board present as both
/dev/cu.usbmodem1101and/dev/tty.usbmodem1101):DeviceWatcherFactory.Create()returnsSerialPortPollingDeviceWatcherhere — PASSGetPortNames(), board left plugged in, ~25 polls: 0DeviceRemoved— PASS (no false disconnects, the thing most likely to bite a user)DeviceRemoved— PASSDeviceRemoved(discovery owns arrivals) — PASSGetPortNames()throwing: 0DeviceRemoved, and polling resumes when it clears — PASSConnectionManager wiring, against the running singleton:
ConnectionManager.Instanceconstructs with noTypeInitializationException— PASSHotplugDetectionUnavailableMessageisnullon macOS (detection working, no nag) — PASS_deviceWatcheris the polling backend and its poll timer is running — PASSDeviceRemovedhas exactly one subscriber,Daqifi.Desktop.ConnectionManager.<.ctor>b__24_0— PASS. This is the subscription the old code never reached on macOS: the WMI constructor threw beforeEventArrivedwas ever hooked up.Real hardware still works through the app's own paths:
SerialDeviceFinder, driven the way the connection dialog drives it: discoversNq1 S/N=9090539562006014104 /dev/cu.usbmodem1101— PASSSerialStreamingDevice.Connect()on that port: connected, reported S/N 9090539562006014104, fw 3.7.2, then disconnected cleanly — PASSWhat I did not verify
The physical unplug itself was not performed — nobody was at the bench to pull the cable, and there is no software way to make a USB node disappear on macOS. The removal transition above is driven over the real port strings this Mac reports, which is the exact input the poller would see one tick after a real unplug, but the physical event was not reproduced. Worth knowing if you do reproduce it: a physical unplug also power-cycles the bus-powered device, which closing a port in software does not.
Consequently
CheckIfSerialDeviceWasRemoveditself was never driven end to end against a live Avalonia dispatcher — the watcher side of the wiring is verified (correct backend, running, correctly subscribed), and the handler's UI marshalling is reasoned and build-verified, but nothing at the bench actually pulled a connected device out from under it.Windows (
WmiDeviceWatcher) and Linux were not exercised. The Windows backend is the upstream code moved behind the interface, unchanged; Linux takes the same poller as macOS. Android and iOS were not run — they takeNoOpDeviceWatcher, and the claim there is only that the constructor no longer throws, which follows from the platform branch.Re-validated after main moved
maingained #149 (DaqifiAppVersionsingle-sourcing inDirectory.Build.props, the newAppVersiontype, and the Sentry release change) after this branch was cut. Currentmainis merged in here rather than left to GitHub's test-merge, and everything above was re-run on the merged tree — no file overlap, no conflicts, and the warning counts in the build gate are measured against that same currentmain.Re-confirmed on the merged head, at the bench with the board attached (non-destructive only — no flash, no SD write, no reboot):
DeviceWatcherFactory.Create()returnsSerialPortPollingDeviceWatcher— PASSSerialPort.GetPortNames()enumerations with the board attached: 0DeviceRemoved— PASSDeviceRemoved, no re-fire while it stays gone — PASSConnectionManager.Instanceconstructs;HotplugDetectionUnavailableMessageisnull;_deviceWatcheris the poller with a live timer;DeviceRemovedhas exactly one subscriber,Daqifi.Desktop.ConnectionManager.<.ctor>b__24_0— PASSLEVEL=INFO: USB hotplug detection backend: SerialPortPollingDeviceWatcher (ConnectionManager..cctor => ConnectionManager..ctor => AppLogger.Information)— the same call site that used to carry thePlatformNotSupportedException, with no error anywhere in the log — PASSThat log check was made headlessly against the real
ConnectionManagerstatic-constructor path, not by launching the window (the re-validation ran without a window-server session, where Avalonia's macOS render timer cannot start). It is the same call site and the same logger, but the GUI itself was not re-launched on the merged head.Also updated
docs/RUNBOOK-macos-ios.md§1.3 documented this as an open "guarded, but degrades silently" finding with a decision still to make. Rewritten to describe the fix and what to expect at startup on each head.🤖 Generated with Claude Code