perf(discovery): serial discovery no longer spends half its time waiting on its own reader thread - #509
Conversation
… thread (closes #486) The probe signalled "status arrived" through a TaskCompletionSource with inline continuations, and that source is completed from inside the message consumer's dispatch — on the reader thread. Everything after the wait therefore resumed on the reader itself, including the teardown, whose first act is to join that very thread. The join could never succeed: it burned its whole 500ms budget on every successful probe and returned with the reader still running over a port the caller was about to close. Completing the source asynchronously puts the teardown back on a pool thread, which then exposes the second half — the reader can only notice a stop request when its blocking read returns, so the join was bounded by the probe port's 1s ReadTimeout. That is pure stop latency for this port (the response deadline is the probe's own poll loop), so it drops to 50ms, the same granularity the poll loop already runs at. The write timeout stays at 1s. The request/reply exchange moves into RequestDeviceStatusAsync so it can be driven by a scripted stream in tests; TryGetDeviceInfoAsync keeps the port lifecycle. Teardown still completes before the probe returns, so a caller can still open the port the instant discovery hands it back. Bench (Nq1, fw 3.7.2, USB, macOS): DiscoverAsync 834/836/839ms -> 363-394ms under identical conditions, 313-320ms with a warm USB-descriptor cache; the abandoned-claim drain 490ms -> 57ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoFix serial discovery self-join and reduce probe teardown latency
AI Description
Diagram
High-Level Assessment
Files changed (4)
|
Code Review by Qodo
1.
|
… identifies Qodo review round 1: dropping the probe port's ReadTimeout to 50ms for the whole probe made a port with nothing to say wake twenty times a second instead of once, each wake-up ending in a thrown TimeoutException. Measured on the bench: ~180ms of CPU per 2s of probing against ~5-13ms at the one-second timeout, and a silent port is probed for the full response window. The short timeout is only needed for the tail of a probe that succeeded, so it is now applied from inside the status dispatch — which runs on the reader thread between two reads, the one moment the timeout can change with no read in flight under the old value. The next read, the one teardown waits out, picks it up. The port opens at the same 1s it always did, so a silent port idles exactly as before and the churn regression is gone rather than traded against. Bench unchanged by this: DiscoverAsync 373-390ms under the same conditions that measured 834-839ms before the fix; abandoned-claim drain 53-56ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 1 — Exception-driven polling overhead: valid, taken ( I measured it before deciding, and it was bigger than I would have guessed. Running the real consumer loop over an idle bench port for a fixed 2 s window, marginal reader CPU (idle baseline subtracted, two runs each):
A silent port is read for the whole Rather than trade latency against churn (100 ms was the obvious compromise, and it put So the finding is closed rather than accepted-with-a-tradeoff: the fast path keeps the full teardown win and the silent path is unchanged. Two tests pin both halves, and the first was proven to catch its own bug — deleting the
Re-verified on the bench after the change: Full suite green on net9.0 (2930 Core + 43 Mcp) and net10.0 (2930), 0 failures, 0 warnings. |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 99531c8 |
Qodo review round 2: RequestDeviceStatusAsync documents the stream as caller-owned and left open, then shortened its ReadTimeout for teardown and never put it back — a caller-visible side effect on someone else's stream, and one my own two-exchanges-over-one-stream test was already exercising. The original is noted before anything touches the stream and restored in the finally, strictly after the consumer join (which is what the short timeout is for). Both directions stay best-effort: a stream that has no timeouts, or refuses them, costs a slower teardown rather than a failed probe. The restore writes only when the value actually differs, so a probe that shortened nothing leaves the stream untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 407f83a |
… a clock race Two test-only fixes. Qodo review round 3: CanBeRunTwiceOverTheSameStream claimed each exchange starts from the restored timeout but only asserted the first read of the FIRST exchange, so the regression it exists to catch — the second exchange inheriting the shortened 50ms — could pass it. The second exchange's opening read is now indexed explicitly, and removing the restore call fails this test as well as the dedicated one. And the cancellation test asserted that no retry had gone out yet, which races a 300ms RetryIntervalMs timer against wherever the scheduler puts the cancellation — it failed on CI (net10.0) doing exactly that. Replaced with the bound that is actually always true: a probe never exceeds MaxRetries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit c3cc25c |
|
Qodo-clean, CI green — ready for review. Round 4 on head Three rounds of findings, all taken:
The same push as (3) also fixed a flake of my own that turned CI red on Full suite green on net9.0 (2932 Core + 43 Mcp) and net10.0 (2932), 0 failures, 0 warnings, on all four pushes; the new test class also ran five times back-to-back locally with no failures. Bench was re-run on real hardware after the last production change ( Not merging — this is for your review. |
Review status looks clean based on the supplied Round 4 results: there are no active Qodo findings and no unresolved review threads. The previously reported issues are addressed: finding 1 restores the caller-owned |
…rdown-486 # Conflicts: # src/Daqifi.Core/Device/Discovery/SerialDeviceFinder.cs
|
Merged Two conflicts, both resolved the obvious way: the probe body keeps this PR's extracted Re-verified after the merge rather than assumed. Full suite green net9.0 (2972 Core + 43 Mcp) and net10.0 (2972 Core), 0 failures, 0 warnings. Bench re-run non-destructively on fw 3.7.2 / /agentic_review |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 1705eb8 |
|
Qodo-clean, CI green, conflict gone — ready for review. Round 5 on head Not merging — yours to merge. |
I can’t merge the PR here. Based on the information provided, the head is clean, CI is green, and there are no active Qodo findings; I can still explain the implemented findings, review a specific change, or help assess merge readiness. |
What was wrong
Finding a DAQiFi device over USB took about 830 ms, and roughly 500 ms of that was the library waiting on itself.
The probe asks the device "who are you?", and the reply arrives on a background reader thread. Because of how the "reply arrived" signal was set up, everything that happened after the reply — including the cleanup — ran on that same reader thread. The first thing the cleanup does is wait for the reader thread to finish. So the reader thread sat there waiting for itself. It could never succeed, so it waited out its full 500 ms timeout and then gave up, every single time a device was found.
It also meant the probe handed the port back to the caller while its reader was still running over it — the port got closed out from under a live reader.
How it was fixed
Two things, and the second only became visible once the first was fixed.
The reply signal now resumes on a normal worker thread instead of the reader thread, so the cleanup is waiting for a different thread and the wait can actually complete. That alone exposed the real remaining cost: the reader only notices "please stop" when its in-progress read gives up, and the probe port's read timeout is 1 second — so the honest wait was up to a full second, worse than the 500 ms it replaced. So the moment the device answers, the probe drops that read timeout to 50 ms. It does this from inside the "status arrived" callback, which runs on the reader thread between two reads — the one moment the timeout can be changed with no read already in flight under the old value — and the next read is exactly the one the cleanup has to wait out.
The port still opens at the 1 second it always did. A port with nothing to say never reaches the callback, so it idles exactly as it did before.
What a reviewer may want to push back on:
TimeoutException. That was measured, not waved away: ~180 ms of CPU per 2 s of probing, against ~5-13 ms at the one-second timeout, and a silent port is probed for the full response window. Applying it only after a device has identified keeps all of the teardown benefit and leaves the silent path byte-for-byte as it was. A test pins both halves.DiscoverAsyncreturns succeeded 10/10, in 2-4 ms.MinDiscoveryTimeoutMs) stays at 1000 ms. The issue lists lowering it as a success criterion, and the ~830 ms measurement it was derived from is now obsolete — but the new measurement is one macOS host, one candidate port, warm descriptor cache. The floor has to cover the slowest supported path, including platforms that still run an uncached USB descriptor query per port (perf(discovery): Windows runs one uncached WMI query per COM port per sweep — batch and cache like the macOS provider #487). Lowering it on the strength of the fast case is how you land back in mcp: discover_devices clamps the timeout to a 250 ms floor, but serial identify takes ~830 ms — any short timeout silently returns no devices #448, where an empty result is indistinguishable from "nothing attached". The comment now says all of that instead of quoting a stale number.RequestDeviceStatusAsync), leavingTryGetDeviceInfoAsyncowning just the port lifecycle. That is what makes the bug testable at all — the exchange can now be driven by a scripted stream with no serial hardware attached.Verification
Tests — 11 new in
SerialProbeTeardownTests. Two are regression catchers and were proven to be: with only the one-line signal change reverted,DoesNotCompleteOnTheReaderThreadandWhenItReturns_ReaderThreadHasExitedboth fail, twice out of two runs. Neither uses a wall clock — they assert that the thread reading the stream has exited by the time the exchange completes, and that the completion did not run on it. Two more catchers cover the review-round fixes and were proven the same way: removing the timeout-shortening call failsAfterIdentifying_ShortensTheReadTimeoutForTeardown, and removing the restore fails bothAfterIdentifying_RestoresTheReadTimeoutItFoundandCanBeRunTwiceOverTheSameStream. Counterparts assert a silent port's timeout is left untouched and that a stream refusing timeout access still probes fine. The rest cover the parsed status, the silent-device retry count, cancellation, stream ownership, and running two exchanges back-to-back over one stream.Full suite green on net9.0 (2932 Core + 43 Mcp) and net10.0 (2932), 0 failures, 0 warnings, on every push.
Bench (non-destructive), Nq1 fw 3.7.2 on
/dev/cu.usbmodem1101:SerialDeviceFinder.DiscoverAsyncBoth rows measured on the same host and device with the same harness; the "after" run uses 2.5 s spacing so every run pays the macOS
ioregdescriptor refresh exactly like the baseline runs did. That refresh (~85 ms, 2 s TTL) is the whole difference between the two "after" rows and is outside the probe.Also: back-to-back sweeps with zero delay found the device 16/16 across both pushes; discover-then-immediately-open succeeded 10/10, in 2-4 ms; example CLI
--discover-serial,--show-status, and a 3 s / 500 Hz stream (1186-1187 samples, this unit's known ~79% clock ratio) and--sd-list(45 files) all clean,sn=9090539562006014104, firmware unchanged, cleanDisconnected. No reboot, no format, no delete, noSD:GET, no firmware, no LAN writes; serial only.closes #486
Not merging — this is for your review.