Repository navigation
test(coverage-gap): pin SerialPortPollingDeviceWatcher — the only USB-unplug detector on macOS/Linux (16 cases, 12/13 mutants killed, 1 documented) - #404
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoCharacterize POSIX serial-port removal polling
AI Description
Diagram
High-Level Assessment
Files changed (1)
|
Code Review by Qodo
1. A failed race test strands a worker
|
|
/agentic_review |
| if (held is { } h) | ||
| { | ||
| h.Entered.Set(); | ||
| h.Release.Wait(); |
There was a problem hiding this comment.
1. A failed race test strands a worker 🐞 Bug ☼ Reliability
PortTable.Read waits on h.Release without a timeout, while the race test calls release.Set() only on its success path. If the 10-second assertion or either lifecycle call fails after the callback claims the held read, watcher disposal does not wait for or cancel that callback, so a thread-pool worker and the fixture remain retained for the rest of the test process.
Agent Prompt
## Issue description
The held provider read is released only on the race test's success path. An assertion or lifecycle failure can therefore leave the timer callback blocked indefinitely even after the watcher is disposed.
## Fix Focus Areas
- Daqifi.Avalonia.Tests/Services/SerialPortPollingDeviceWatcherTests.cs[102-106]
- Daqifi.Avalonia.Tests/Services/SerialPortPollingDeviceWatcherTests.cs[362-367]
## Recommended Fix
Wrap the race-test operations after `HoldNextRead` in a `try`/`finally` and call `release.Set()` from the `finally` block, making the release idempotent and guaranteed on every failure path.
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
There was a problem hiding this comment.
Valid. Conceded. The held read is released only on the success path, and disposing the watcher does not wait for an in-flight callback, so a failed assertion there would leave a thread-pool worker blocked for the rest of the run. The failure path only matters when the test is already failing. The code under test is unaffected.
Fixed in 5950ff6 (the test body is wrapped in try/finally, and release.Set() runs in the finally; Set is idempotent). Gate green on that commit: desktop exit 0, iOS Release exit 0, tests 1009/1009. It could not be pushed here: this PR is in the merge queue, and the queue rejects pushes to a queued branch (GH006). The shepherd did not dequeue it. The commit is parked on branch coverage-gap-serial-port-polling-release-fix. Leaving this thread unresolved because the fix is not on this PR's head.
|
Code review by qodo was updated up to the latest commit fd65a8f |
|
Shepherd status: NOT marked ready. One valid Qodo finding is open, and its fix is blocked by the merge queue.
|
Not merging. This is for review.
What was untested, and why it matters
On macOS and Linux,
SerialPortPollingDeviceWatcheris the only code that notices a USB device being unplugged. It samplesSerialPort.GetPortNames()on a timer and raisesDeviceRemovedwhen a port it saw before is gone.ConnectionManagerthen runsCheckIfSerialDeviceWasRemoved.It can go wrong in two ways, and the user pays for both:
The class has three guards against the second failure:
None of these had a test. The class had no test at all. Proof: a case-folded search of
Daqifi.Avalonia.Testsforserialportpollingreturns 0 files. The same search fordiskspacemonitor(positive control) findsDiskSpace/DiskSpaceMonitorTests.cs.ConnectionManagerTeardownTestsusesNoOpDeviceWatcher, so this class is never exercised there either.What this adds
This PR adds one test file,
Daqifi.Avalonia.Tests/Services/SerialPortPollingDeviceWatcherTests.cs, with 16 cases (14 facts, plus one theory with 2 rows). There are no production changes. The tests pin what the class does today:Starttakes the baseline synchronously, before any poll.Startis a no-op.StopbeforeStartand a doubleDisposeare harmless.StartafterDisposethrows. A non-positive poll interval is rejected (ParamName == "pollInterval")."Serial port hotplug poll failed.", and polling continues and reports the next removal.No sleeps. The tests drive the real timer and synchronise on the injected port provider:
finally, after the event has been raised."No event was raised" is asserted only after that has been observed. The only wall-clock value is a 10 s failure timeout on that wait.
The fake
IAppLoggeris minimal and private. It can fold into the sharedRecordingAppLoggerfrom #401 once that merges. This PR does not depend on #401.Evidence the tests bite: mutation testing
Every test passes on unmodified
main, as characterisation tests should. So I mutated the production file 13 times. Each mutation was a single fresh edit, and the file was restored andtouched between runs. For every run I confirmed that 16 tests executed.<=→<A_poll_interval_that_is_not_positive_is_rejected(0)_timer != nullearly return inStartA_second_Start_is_a_no_op_generation != generationfrom the in-lock check…cannot_poison_the_new_baseline!IsSubsetOf→Count >One_port_swapped_for_another…OrdinalIgnoreCase→OrdinalPort_names_are_compared_ignoring_case_enumerationFailureLogged = falseresetAn_enumeration_outage_is_logged_once…A_failed_read_is_not_mistaken…Starttakes no baselineA_throwing_subscriber…ObjectDisposedExceptionguard inStartStart_after_Dispose_throws12 of 13 killed. Mutation 13 survives, and the test's doc comment says why. That re-check only covers a
Stopthat lands between the lock being released and the event being raised. A test cannot hold that window open without adding a hook to production code, which this PR does not do. The stale-reading test deliberately uses a superset reading. A stale reading that drops ports would be caught by both checks, so it could not tell whether the in-lock check had been removed. The superset reading can.I did not run one further mutation: removing the poll's
catchentirely. An exception thrown from a timer callback terminates the test host, so that "kill" would take down the whole run.Verification
dotnet buildDesktop: exit 0.iossimulator-arm64 --no-restorebuild: exit 0.dotnet test Daqifi.Avalonia.Tests: 1009 passed, 0 failed (16 of them new).packages.lock.json: unchanged.Scope
DaqifiViewModel.cs, and nothing in Devices pane paints one or two frames with the settings drawer forced open on every tab visit (pre-OnLoaded DataContext) #397's scope.VersionNotificationwas also untested, but I passed over it: fix(update): the version check queries daqifi-desktop, reads the wrong assembly, and Sentry-errors on any prerelease tag #345 slates it for deletion.Diffstat: one new test file, +459/−0; production untouched. Net-additive because it is a test-only coverage PR: nothing got smaller, one previously unguarded class now has its guards pinned.
🤖 Generated with Claude Code