feat(device): cancellable async connect/disconnect and IAsyncDisposable (closes #341) - #416
Conversation
…le (closes #341) Adds ConnectAsync/DisconnectAsync to the device surface, implements IAsyncDisposable on DaqifiDevice, and threads a CancellationToken through IStreamTransport.ConnectAsync so an in-flight dial can be abandoned. The new transport overloads ship as default interface implementations that forward to the existing uncancellable ones, so third-party IStreamTransport / IDevice implementations keep compiling unchanged. The synchronous Connect, Disconnect and Dispose remain and behave exactly as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… harden the retry cancel check Review follow-ups: a virtual async twin of a non-virtual sync method invites a subclass to override one and silently bypass the other, and a retry policy with zero backoff had no Task.Delay in which to notice a cancellation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoAdd cancellable async connect/disconnect and IAsyncDisposable for devices
AI Description
Diagram
High-Level Assessment
Files changed (12)
|
Code Review by Qodo
1.
|
…ke disposal single-shot Both from Qodo review on #416. The default IStreamTransport.ConnectAsync(retryOptions, token) ignored the token outright, so a legacy transport would open a socket — or pulse DTR and reset the MCU on a serial open — for a caller that had already cancelled. It now refuses to start, matching IDevice's default shim. Dispose() and DisposeAsync() both gated on a flag published only at the end of teardown, while DisposeAsync spends real awaited time inside DisconnectAsync. A concurrent Dispose() could enter that window and run a second teardown, which the XML docs explicitly promised would not happen. An interlocked claim taken at the start of disposal makes that promise true; teardown also moved into a finally so a throwing disconnect no longer leaks the handles on a device that can never be disposed again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both Qodo findings addressed in 66d2ff5 — see the inline replies for the reasoning on each. 1. Transport cancel precheck missing — real defect, fixed. The default 2. Dispose overlap race — real defect, fixed. An interlocked claim taken at the start of disposal replaces the flag published at the end. I did not adopt the suggested shared- Three regression tests added, each verified to fail against the pre-fix code. Tests: Release build 0 warnings. Full suite green on net9.0 and net10.0 — 2219 passed, 2 skipped, 0 failed on each. MCP suite 23/23. Bench re-run (Nyquist 1, FW 3.7.2, USB only — WiFi skipped to avoid connect churn on a shared unit). The disposal path is what the bench measures, so it was worth re-confirming even though both fixes are pure guard/synchronization changes with no wire-level effect:
Unchanged from the pre-fix bench within noise. /agentic_review |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 66d2ff5 |
Resolves against #415 (background-error surface, connection-loss escalation) and #417 (SD->LAN restore inside the exchange lock). Three conflicts, all where #415 edited the same connect/disconnect bodies this branch factored into shared sync/async step sets: - IDevice.cs: #415's ErrorOccurred event landed immediately before Connect(), whose doc comment this branch rewrote. Kept both. - DaqifiDevice.Connect(): #415's consumer ErrorOccurred subscription moved into the shared CompleteConnect(), so the async path wires it too. - DaqifiDevice.Disconnect(): #415's ErrorOccurred unsubscribe moved into the shared StopMessagePumps(), reached by both Disconnect() and DisconnectAsync(). _errorThrottle.Reset() moved from Connect() into the shared BeginConnect(). Leaving it on the sync path alone would have quietly dropped #415's per-session reset from the primary connect path, since the factory now connects through ConnectAsync. Nothing in #415's suite covers that reset, so it would have survived a fully green build. Added two regression tests for that seam — every #415 test drives Connect(), because ConnectAsync() did not exist when they were written. Both verified to fail when the connect-side wiring is dropped. OnTransportStatusChanged, the _isDisconnecting guard and #417's finalizeAsync plumbing are byte-identical to main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nnect #414 (opt-in connect that leaves a running stream alone) landed while the previous merge was being resolved. One conflict, in the docs' Advanced snippet, where #414 added a note about InitializeAsync stopping a running stream to the same block this branch had switched to the async/cancellable calls. Kept both: the async calls carry the token, and #414's PreserveActiveStream guidance stays. DaqifiDevice.cs and DaqifiDeviceFactory.cs auto-merged and were reviewed by hand rather than trusted: #414 confined itself to InitializeAsync and a new PreserveActiveStream property, which is disjoint from the connect/disconnect/ dispose restructuring here. In the factory, #414's PreserveActiveStream object initializer still runs before the connect call, preserving its "never observed half-applied" invariant now that the call is ConnectAsync. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Merged Conflicts (4)
The one that would have slipped through. Worth stating plainly: nothing in #415's suite covers that reset. I verified by deleting it outright — all 23 of #415's error-surface/throttle/loss-escalation tests still passed. It would have shipped green. Two regression tests added for that seam, since every #415 test drives
Both verified to fail when the connect-side wiring is dropped, and to pass with it. Verified unchanged from Tests: Release 0 warnings. 2276 passed / 2 skipped / 0 failed on both net9.0 and net10.0. MCP 23/23. No bench re-run — the merge added no wire-level change beyond what #414/#415/#417 already benched. /agentic_review |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 91c6e03 |
Qodo review on #416: the reconnect test bounded nothing, so on a slow or loaded runner the two errors could fall more than five seconds apart, the second raise would be due regardless of the reset, and the test would pass with _errorThrottle.Reset() deleted — the exact regression it exists to catch. The assertion now rests on SuppressedCount rather than on elapsed time. A reset clears the bucket, so a fresh session's first raise has nothing collapsed behind it; a bucket that survived the reconnect carries the previous session's count forward whenever it eventually fires. That holds however long the run took. With the reset dropped the count is 46, not a marginal one or two. The five-second window is still checked, but only as a precondition and only after the count is known clean: if the errors did fall outside one window the run cannot distinguish the two cases, so it fails loudly rather than banking a pass that guards nothing. Ordered second so the common regression reports the real cause instead of "inconclusive". Verified: five consecutive passes with the reset in place (~256ms each, the gap being a fraction of the window), three consecutive failures without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 17f39a6 |
Qodo review on #416: the precondition added last round hard-fails when the two errors land more than DeviceErrorThrottle.DefaultInterval apart, so a loaded runner could fail a correct implementation. Both that finding and the previous one are right, and reverting either way just oscillates between them: without a bound a slow run passes while proving nothing, with one a slow run fails while nothing is wrong. The bound was never the problem — depending on wall-clock time at all was. DaqifiDevice now takes an injectable error throttle (internal, matching the existing SetSerialPortForTesting / ConnectTaskFactory seams), and the test installs a ten-minute window. A reset clears the bucket and the new session raises immediately; without the reset the bucket stays shut for ten minutes, so the wait times out and names the defect. Neither outcome can be reached by the machine being fast or slow. The window check survives as a backstop on the test's own premise, but against ten minutes it is unreachable in practice. Verified: 5 consecutive passes with the reset (~250ms each), 5 consecutive failures without it, each reporting that the reconnected session's first failure was collapsed into the previous session's window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 1895c1e |
Not merging — for review.
closes #341
Why
Connecting to a device and closing it were the last two things in daqifi-core you could not do
without blocking.
Disconnect()waits up to ten seconds for any command still in flight, andDispose()runs that wait on whatever thread happens to be disposing — in the WPF desktop app,that is the UI thread, and the app visibly freezes. There was also no way to give up on a connection
attempt once it started: if a user hit Cancel while the app was dialling an unresponsive device,
nothing happened until the connect timed out on its own.
What
The device now has async twins of the three lifecycle methods, and cancellation reaches all the way
down to the transport:
ConnectAsync(CancellationToken)andDisconnectAsync(CancellationToken)on the device.await using var device = ...now works, and never blocks the caller.address configured with a 60-second timeout and five retries returned in 513 ms instead of
waiting the timeout out.
landed, it gets closed again rather than left dangling.
DisconnectAsynctreats its token slightly differently, on purpose: cancelling it skips the waitfor an in-flight command and goes straight to teardown. It never aborts the disconnect half-way,
because a half-torn-down device is worse than a slow one. This is documented on the method.
Nothing is removed.
Connect(),Disconnect()andDispose()all still exist and do exactly whatthey did before.
How
Both the sync and async paths now share one set of internal steps, so they cannot drift apart — the
only difference is whether the transport is opened by a blocking call or an awaited one.
For the two public interfaces, the new cancellable methods are added as default interface
implementations that fall back to the existing uncancellable ones. That means anyone who has
written their own
IStreamTransportorIDevicekeeps compiling and working with no changes; theysimply do not gain cancellation until they override the new member. The transports shipped here do
override them.
Is anything breaking?
No. Not source-breaking and not binary-breaking:
IStreamTransportandIDevice— including ones already compiled against an earlier version —remain valid.
DaqifiDevicegainedIAsyncDisposablealongsideIDisposable. Existingusing var device = ...code is unaffected.
ConnectRetryExecutor) is not public.The only behavioural change on an existing path is inside the connect factory, which now passes the
caller's cancellation token down to the transport — previously it checked the token only between
steps. That is the fix, not a side effect.
Testing
green (23/23). Release build with zero warnings.
backoff, async disposal, the "cancel arrived after the transport opened" cleanup, a transport
written against the old interface still working through the new overload, and that sync
Connect/Disconnect/Disposebehave exactly as before.Bench (Nyquist 1, HW 2.0.0, FW 3.7.2)
await using— connect, stream 5 s @ 100 Hz, async disposeawait using(single connect)OperationCanceledExceptionafter 513 msConnect()/Disconnect()/Dispose()Message counts are ~79 % of the requested rate, which is the known firmware clock issue on this
unit, not a regression.
🤖 Generated with Claude Code