feat(device): opt-in auto-reconnect and streaming session resume after a drop (closes #379) - #418
Conversation
… the last silent-spin path Adds a device-level error event so read, parse, dispatch and per-frame decode failures are observable instead of silent, and closes the one remaining place the reader loop could spin forever with no data, no error and no status change. - IDevice.ErrorOccurred + DeviceErrorEventArgs / DeviceErrorSource - DaqifiDevice subscribes to the message consumer's ErrorOccurred (protobuf and text consumers), logs every failure, and raises it under a documented throttle (first occurrence immediate, then at most one per 5s per source+exception type, with the collapsed count reported) - DaqifiStreamingDevice keeps per-frame decode isolation but now counts failures (DecodeFailureCount, reset per streaming session) and raises the event - StreamMessageConsumer escalates a permanently unreadable stream instead of backing off silently forever Purely observational: nothing here changes stream behaviour, retry policy or ConnectionStatus. The transports keep sole ownership of declaring a link lost. closes #377 closes #394 closes #378 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r a drop A dropped connection could only ever be reported, never recovered: the caller had to rebuild the whole session by hand. Devices now do it themselves, if asked. - ReconnectOptions (Enabled/MaxAttempts/backoff/ResumeStreaming) on DaqifiDevice, off by default, so today's behaviour — surface Lost and stop — is unchanged - On Lost: snapshot the session, then reconnect the transport, re-run InitializeAsync and replay the enabled-channel set and any running stream - Progress on ReconnectAttempt / Reconnected / ReconnectFailed plus the existing Retrying and Failed statuses; giving up also logs and raises ErrorOccurred with the new DeviceErrorSource.Reconnect - Cancellable via CancelReconnect(); a caller-issued Connect/Disconnect/Dispose always supersedes an in-flight loop Same-endpoint only: a device that moved port or address needs a fresh connect, and cross-transport failover is out of scope. closes #379 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ts scope Addresses Qodo review on #415, plus a process-crash hazard the regression test exposed while verifying the fix. - Text exchange: the temporary consumer's error forwarding is now scope-bound (ConsumerErrorSubscription), so a reader that outlives the exchange's bounded stop/dispose can neither retain the device nor keep raising errors on it. - CanRead probe: a throwing readability getter is a stream fault and is handled like a failing read (health sink + error + backoff) instead of falling to the outer catch, which neither reported nor backed off. - Outer catch: added a backoff. A parser that throws on the bytes it holds throws on the same bytes next iteration, so retrying at full speed was a hot spin — measured at 59k error raises in 700ms. Deliberately not reported to the health sink: a parse failure is not evidence the link is gone. - Outer catch is now unconditional. `when (_isRunning)` left a hole where a stop landing mid-try made the exception escape a background thread and terminate the host process (it crashed the test host). Only reporting was ever meant to be conditional. Six regression tests added, each verified to fail on the pre-fix code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- A reconnected device reported IsStreaming from before the drop when the policy had ResumeStreaming off, so it claimed to be streaming while idle and made the caller's own StartStreaming() a silent no-op. The flag is now cleared whenever a session is restored, since re-initialization has just stopped the stream. - A consumer that tore the device down from inside its own Lost handler — the pattern the docs show for devices without a reconnect policy — had the loop start anyway and reopen the transport behind them. The drop now carries the session epoch it was observed at, and a reconnect refuses to start unless the device is still sitting on that same lost session. Also documents that reconnect-enabled consumers should stop tearing down on Lost themselves, and that reassigning ReconnectOptions mid-loop applies from the next drop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoOpt-in auto-reconnect with session restore + surfaced background errors
AI Description
Diagram
High-Level Assessment
Files changed (21)
|
Addresses Qodo round 2 on #415. The CanRead paths reported I/O faults even after StopSafely() cleared the running flag, contradicting the outer catch's teardown-noise rule added in the previous commit — the same event got two different answers depending on which path caught it. All stream-fault sites (CanRead throw, CanRead false, read exception, socket EOF) now route through one ReportStreamFault helper that states the rule once: nothing is reported once a stop has been requested, and the loop exits at once instead of sleeping out a backoff it no longer needs. Parse/dispatch failures deliberately stay outside it — they are not evidence the link is gone. Checked whether this could breach #377's "intentional Disconnect() never reports Lost": it could not. A continue re-tests the loop condition, so at most ONE fault could ever be reported after a stop, against an escalation threshold of five consecutive — measured at exactly 1 with the guard removed. The transports also disarm their watchdog before touching the handle, and DaqifiDevice._isDisconnecting independently suppresses Lost. Diagnostic noise, not a hole in the guarantee — but noise the new device-level error event would have made user-visible on every disconnect. Two regression tests, both verified to fail on the pre-fix code, plus a teardown-silence assertion on the existing device-level disconnect test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Code Review by Qodo
1.
|
…the reader loop Addresses Qodo round 3 on #415. ReportStreamFault invoked ITransportHealthSink and ErrorOccurred without isolation, so a throwing callback escaped the reader thread — process-fatal, the same shape as the escaping-catch defect fixed last round, one layer up. Concentrating the four fault sites into one helper made a single unguarded callback affect all of them at once. The route is two hops, and the crash trace confirms it exactly: the handler throws on the fault path, the loop's outer catch reports that failure by calling the same handler, and the second throw is inside a catch block with nothing above it. Verified against pre-fix code — the test host dies with the unhandled exception surfacing from ProcessMessages' outer catch. Every callback out of the loop is now isolated via SafeReportIoFault / SafeReportIoSuccess / SafeRaiseError: the two ReportStreamFault callbacks, the per-read success report, the outer catch's error raise, and the error raise in ProcessMessageBuffer's dispatch handler. Plain methods rather than a lambda helper so the once-per-read success path allocates no closure. Swallowed rather than logged, matching the convention already used for a throwing MessageReceived subscriber (#180) and mirrored across RaiseClassifiedEvent (#323), AllTransportsDeviceFinder (#354), DeviceFinderBase and RaiseGapDetected. Two regression tests assert the reader keeps consuming — a real message delivered after the throwing phase — not merely that nothing surfaced. Both verified failing pre-fix: the subscriber case crashes the host, the health-sink case silently stops delivering messages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…our a zero backoff Two findings from review. A caller's Disconnect() lands *inside* an in-flight reconnect attempt, not tidily between two — opening a port blocks for as long as it takes. The attempt then finished, reopening the transport, starting a reader and setting Connected, and the loop's staleness check bailed out without unwinding any of it: a device the caller had closed came quietly back to life. The check cannot simply move earlier (the race is the blocking call itself), so the loop now records what the caller actually wanted and puts its own half-built session back down after Connect, and again after initialization and restore, before declaring success. CalculateDelay turned InitialDelay=Zero into MaxDelay at very high attempt numbers: the exponential factor overflows to infinity and 0 x infinity is NaN, which the NaN guard answered with the cap — the opposite of a policy asking for immediate retries. Zero in, zero out. MaxDelay now also caps the first attempt, so the ceiling means what it says. Six regression tests, each verified to fail against the pre-fix code — the teardown race reproduces as "Expected: Disconnected, Actual: Connected". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Note for reviewers: this PR currently has no CI checks, and that is not a pass. The build workflow triggers on PRs targeting Until that happens the only build signal is local, so for the record, on b130df6:
Both review findings are addressed in b130df6, each with regression tests confirmed to fail against the pre-fix code. |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit b130df6 |
Addresses Qodo round 4 on #415. RecoveringStream.Read copied min(payload, count) bytes and then discarded the unread suffix, so the tests that depend on it were silently coupled to the consumer's read buffer being larger than the payload — they would have kept passing for a reason unrelated to the code under test, and would have started failing on an unrelated bufferSize change. A test double that violates the contract is a latent false-negative generator, and these tests are the evidence for this PR's claims. RecoveringStream now retains the remainder across calls and clears the payload only once fully drained. DeviceErrorSurfaceTests.ScriptedStream had the same defect in a queue nothing enqueues to any more, so the queue is deleted rather than fixed. Added TheRecoveringStreamHelper_DeliversAWholePayloadAcrossPartialReads, which drives the helper with a one-byte read buffer so the partial-read path is actually exercised; verified it fails against the old helper ("the payload never arrived in full"). Re-verified with the corrected helper that the round-3 tests still fail against pre-fix production code: the throwing-subscriber case still crashes the host, the throwing-health-sink case still stops consuming. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… gate waits Two findings from round two. Automatic reconnect introduced a second thread that opens and closes the transport, and cancellation is not synchronization: SupersedeReconnect asks the loop to stop and returns, but a loop already inside a blocking transport connect runs to completion regardless. A caller's Disconnect could therefore be closing the same serial port while the reconnect was opening it, and both threads could build and start a message consumer, leaving two readers on one stream. ConnectCore and DisconnectCore now run under a reentrant lifecycle monitor. Scoped deliberately to the lifecycle pair, not the general per-device operation serialization of #342: it is an internal invariant that the device never drives its own transport from two threads at once, it touches no public behaviour when uncontended, and it leaves the _textExchangeLock ordering question untouched. Reentrant because both methods raise StatusChanged from inside their critical section and a handler calling Disconnect from there must keep working. On timeout it proceeds unsynchronized, which is exactly what shipped before. The scripted test transport and device parked background threads on gate waits of 30s while the assertion timeout is 15s, so a failing test could leave a thread inside the transport long after it gave up. Bounded to 5s, matching the background-wait convention in #364 and #411. Regression tests verified against the pre-fix code: the race reproduces as "a caller's Disconnect was inside the transport at the same time as the reconnect's connect". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 2 addressed in 5e157b4. Reminder that this PR still has no CI — the build workflow triggers on PRs targeting Local signal on 5e157b4:
Both findings had regression tests confirmed to fail against the pre-fix code. |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 5e157b4 |
…d suggestion RunLifecycleExclusive ran the operation anyway when Monitor.TryEnter timed out, having logged a warning first — which is exactly the double-open the lock was added to prevent: two threads both finding no message consumer, both starting one, two readers on one stream for the rest of the session. A guarantee with a "proceed regardless" branch is not a guarantee. That shape was borrowed from _textExchangeLock, and it was the wrong precedent to borrow: a text-exchange timeout degrades one command, this one corrupts the stream. The two callers also want opposite things from contention, so they now say which they want. Connect fails: it waits LifecycleLockTimeout and throws TimeoutException. Nothing was opened and no state changed, so the cost is a retry — far better than a silently corrupted stream. The reconnect loop treats it as an ordinary attempt failure and backs off. Disconnect waits, unbounded. Teardown is the resource-release path and Dispose depends on it, so it may neither fail nor be skipped, which leaves waiting as the only honest option. It cannot deadlock — nothing holding another lock in this class ever waits on a lifecycle operation (_textExchangeLock is taken inside this one, never the reverse), and Monitor grants re-entry immediately to a thread that already holds it, which is what keeps a handler calling Disconnect from inside a StatusChanged raise working. Every possible holder is itself a bounded lifecycle operation. Semantics documented on Connect, Disconnect and the helper. The timeout is now an internal virtual property so tests can reach the contention path, mirroring SdCardDownloadTimeout. Two regression tests, verified against the pre-fix behaviour: the connect one fails with "Assert.Throws() Failure: No exception was thrown". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d-error-surface' into feature/auto-reconnect-and-session-resume
|
Round 3 addressed in c2517f3, and the stabilized base is merged in 5463748. Base merge: merged Reminder that this PR still has no CI — the build workflow triggers on PRs targeting Local signal on 5463748 (post-merge):
The round-3 finding had regression tests confirmed to fail against the pre-fix behaviour. |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 5463748 |
… Dispose Last round's unbounded wait rested on a premise that is false: "every possible holder is itself a bounded lifecycle operation". SerialStreamTransport calls _serialPort.Open() synchronously with no timeout — ConnectionTimeout only sets Read/WriteTimeout, which govern an already-open port — and it is reached while holding the lifecycle lock. This codebase already knows that call can wedge in uncancellable native I/O: SerialDeviceFinder carries a process-wide port quarantine built for precisely that (its comment cites PR #295). So a holder can hang forever, and an unbounded wait inherits the hang, turning Disconnect and therefore Dispose into a permanent block. Teardown now waits TeardownLockTimeout (30s, far more generous than the 10s connect side, because a teardown that gives up early is a teardown that did not happen) and then ABANDONS rather than either racing or hanging — the house answer to uncancellable native I/O here, matching #295's quarantine and #401's bounded SD path. On abandonment the transport is deliberately left to the stuck holder, which is guaranteed to release it: _callerWantsDisconnected is set before the wait, so AbandonIfSuperseded tears down whatever the connect eventually builds. What the abandoned path does do is record the caller's intent in this class's own fields (Status, State, _isInitialized) — safe because they are not the transport, and necessary because otherwise the device keeps reporting itself connected after the caller asked it not to. Dispose still reaches _transport.Dispose() outside the lock, so the handle is released either way. XML docs on Disconnect and the helper corrected: they asserted the bounded-holder claim that this commit disproves. Regression test verified against the pre-fix behaviour, where it fails with "Disconnect blocked for 5037ms behind a wedged connect" — and that 5s was only the test harness's own cap; a real Open() hang has none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 4 addressed in 9882740. The finding was correct and my round-3 justification was factually wrong: Reminder that this PR still has no CI — the build workflow triggers on PRs targeting Local signal on 9882740:
Base is still #415 stable head ( |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit f8a473e |
…le rate TrackSessionCommand set IsStreaming=true the moment a command looked like a start, then validated the rate afterwards. A start whose argument is missing, unparseable or out of range therefore left the device believing it was streaming while StreamingFrequency still held a rate from an earlier session — and a reconnect would faithfully restore that rate. Resuming at a number nobody asked for is the silent-wrong-data mode this feature exists to prevent, and it is the same family as the bug the physical cable pull just caught. The bench cannot have covered it: a valid rate never reaches this branch. A malformed start is now not treated as a session start at all. The firmware rejects such a command and does not begin streaming, so the flag would not have described the device anyway; and a spuriously-true IsStreaming also makes the next real StartStreaming() a silent no-op, the stale-flag trap issue #118 and the SD defensive stops already guard against. Existing state is left alone rather than cleared: a device already streaming at a good rate keeps doing so when the firmware rejects a malformed start, so both flags stay true of it. Clearing them would swap one inaccuracy for another. The upshot is that IsStreaming is never true beside an unvalidated rate, so restore has no "streaming at an unknown rate" case to decide about — the state it replays was always really commanded. Ten tests over the malformed shapes (missing, empty, non-numeric, trailing junk, zero, negative, above the sampling ceiling), the running-session case, the silent-no-op consequence, and the end-to-end reconnect. Nine fail against the pre-fix ordering; the tenth documents the leave-alone rule, which pre-fix also satisfied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 7 addressed in 5bc5cc1 — the finding was in the tracking code added for the bench fix, and it is the same silent-wrong-data family. A start-streaming command whose rate is missing, unparseable or out of range no longer marks the device as streaming, so Bench status: the physical unplug/replug re-verification passed on the previous commit — 1117 CSV rows, cable pulled, 14.8 s outage across three attempts, then 7068 rows flowing again with Reminder that this PR still has no CI (stacked on #415), so the local run is the only build signal. On 5bc5cc1:
|
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 5bc5cc1 |
…tStreaming Round 8, plus a deliberate pass over the whole tracking method rather than only what was listed — three findings in one method said its requirements were being discovered a review at a time. Walking each typed method for effects beyond setting a flag: - StartStreaming did six things this path skipped: timestamp anchor reset, tick frequency, gap detector reset, warmup guard arming, its counter, and the decode-failure count. A raw-started stream therefore decoded against the PREVIOUS session's anchor — a unit test pins it at 60 seconds of error, samples stamped with times that never happened, which is the worst outcome this library can produce. Extracted BeginStreamingSession() and called it from both paths so they cannot drift again. - StopStreaming does nothing beyond the flag, so clearing it was already complete. - EnableChannels assigns IsEnabled under the channels lock and derives the mask from it; the mask is already sent by the time tracking runs, so only the assignment is replayed, over every analog channel because the firmware treats the mask as set-replace. Divergences kept, now documented as decisions: the global DIO enable is one switch for the whole port and carries no per-channel information, so none is inferred; argument validation is not replayed because the device has already seen the command and is the authority on it; and a raw start while already streaming records the new rate without re-anchoring, since the typed API cannot express that case so there is no equivalence to preserve. Also fixed the throwing setter: the rate is validated against a single read of MaxSamplingRate and assigned to the backing field, rather than validated against one read and assigned through a setter that takes another. A stress test flipping the ceiling on another thread reproduces the old behaviour as an ArgumentOutOfRangeException escaping a Send whose command had already gone out. Four of the five new tests fail against pre-fix code; the fifth documents the restart-in-place decision, which pre-fix also satisfied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 8 addressed in c6ddd0b — both findings, plus a deliberate pass over the whole The important one: a raw-started stream was decoding against the previous session timestamp anchor. A new unit test pins that at 60 seconds of error — samples stamped with times that never happened. Reminder that this PR still has no CI (stacked on #415), so the local run is the only build signal. On c6ddd0b:
No re-bench this round, deliberately. The unit test pins the timestamp defect at a precise 60-second error, which is sharper evidence than eyeballing reconstructed timestamps on a live capture, and nothing in the fix changes link behaviour the board could speak to. The hardware has already answered the question it is best at: 1117 rows, cable pulled, 14.8 s outage, 7068 rows flowing again with |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit c6ddd0b |
…t-and-session-resume # Conflicts: # docs/DEVICE_INTERFACES.md # src/Daqifi.Core/Device/DaqifiDevice.cs # src/Daqifi.Core/Device/DaqifiStreamingDevice.cs # src/Daqifi.Core/Device/DeviceErrorSource.cs # src/Daqifi.Core/Device/IDevice.cs
|
Merged What conflicted, and how each was resolvedFive files, ten hunks.
The one real design decisionMy lifecycle lock was a Leaving the async paths outside the lock would have silently reopened the exact race Qodo spent rounds 2–5 on: the reconnect loop connects synchronously, so a caller's So the lock is now a Deletion auditDiffed the merged tree against
Nothing auto-merged was quietly lost. I specifically re-checked the failure mode found on #416 — All five PRs' behaviour confirmed present
Tests
No re-bench: the resolution preserves connect/disconnect behaviour rather than changing it, and the reconnect path is already hardware-validated (1117 rows, cable pulled, 14.8 s outage, 7068 rows flowing again). |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit f5531b7 |
…on down A defect that exists only in the merged result, and the mirror image of the one fixed in round 5 — both are Disconnect() returning success without disconnecting. #341 defines what the token means for DisconnectAsync: it shortens the courtesy wait for an in-flight command exchange, never aborts the disconnect, and never surfaces as an OperationCanceledException. #379's lifecycle lock added a second, later wait that contract never covered, and the token was handed to it — where a cancellation was then classified as "abandon teardown", skipping the message-pump stop and the transport close while still reporting Disconnected. My own comment above that catch asserted a teardown is never abandoned by its own token; the code did exactly that. Worse than a contention edge case: SemaphoreSlim throws for an already-cancelled token even when the semaphore is free, so this fired on EVERY cancelled disconnect, leaving live I/O behind a device reporting itself disconnected. The teardown path now acquires with CancellationToken.None. The wait stays bounded by TeardownLockTimeout, which is what protects against a genuinely wedged holder, so round 4's decision is untouched; and the token still reaches AcquireTextExchangeLockForTeardownAsync inside the teardown, where it means what #341 says it means. The connect path is the opposite case and keeps honouring the token, because ConnectAsync is documented to be abandonable and to throw. Audited the other teardown exit paths for the same "token cancelled" versus "lock unavailable" confusion: the sync acquire passes no token, StopMessagePumps runs under CancellationToken.None, the transport close takes none, and the text-exchange acquire already catches and proceeds. This was the only site. Two tests; the first fails against the pre-fix code with the transport still open after a cancelled disconnect. The second documents that a cancelled teardown still waits out a connect in flight rather than racing it — it passes pre-fix, because round 5's caller-side guard cleans up behind the skipped teardown. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 9 addressed in Local signal on
No re-bench. The change is which |
|
/agentic_review |
1 similar comment
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit f65a2ce |
Why
When the cable blips at hour six of an overnight log, the session is over. The library notices the
drop and says so — and that is all it has ever done. Getting the run back means reconnecting the
transport, re-initializing the device, re-enabling every channel and restarting the stream, all by
hand, in an event handler, on a background thread. Nobody does that, so nobody recovers: the desktop
app just tears down and gives up.
This makes recovery something you turn on rather than something you write.
What
Set
device.ReconnectOptionsand a dropped connection rebuilds itself: the transport reconnects,the device re-initializes, the channels you had enabled come back on, and a stream that was running
starts again — at the same rate, with no code from you.
It is off by default, and off means exactly what happens today: the drop is reported as
ConnectionStatus.Lostand nothing else changes.While it works you can watch it.
ReconnectAttemptsays which attempt is coming and how long thewait is,
Reconnectedsays it worked and how long the outage lasted,ReconnectFailedsays itstopped. The connection status follows along too —
Retryingbetween attempts,Failedif it givesup — so an existing status-driven UI shows progress without subscribing to anything new.
Giving up is deliberately loud: as well as the event and the
Failedstatus, it is logged as anerror and raised on the
ErrorOccurredevent added by #415.CancelReconnect()stops it. So doDisconnect()andDispose()— a caller always wins, includingone who disconnects from inside their own
Losthandler.How
A drop takes a snapshot of the session before anyone else sees it, because a consumer's own handler
is free to start tearing the device down, and by then there is nothing left to remember. A
background loop then works through the attempts, backing off a little further each time, tearing the
dead session down before each try so nothing is left bound to a stream that no longer exists.
Restoring the channels has to replay that snapshot rather than read the channel objects, because a
reconnected device reports its own enabled mask and overwrites what the library thought was on. That
is also what makes the test meaningful: the scripted device comes back reporting everything
disabled, exactly as a rebooted one does, so a restored channel set can only have been re-sent.
A resumed stream is a genuinely new session — timestamps re-anchor and the gap detector resets —
because the device's clock may well have restarted while it was away, and carrying the old anchor
across would invent a gap that never happened.
Reconnected.Outageis the measure of the outage.Design decisions
On the device, not a session wrapper. The issue offered either. A wrapper would have needed the
private status setter, the protected error-raising hook and the device's own text-exchange lock all
widened to public just to do its job — more API surface, for a thing that only ever wraps one class.
The mechanism sits on
DaqifiDevice, andDaqifiStreamingDeviceoverrides two small hooks tocapture and restore its own session state, which is the pattern
OnDeviceInitializingAsyncalreadyuses.
No new
ConnectionStatusvalue.Retrying("retrying connection after a failure") andFailed("failed after all retry attempts") already existed and were unused. Adding
ReconnectingbesideRetryingwould have been two names for one state.Not on
IDevice. #415 already made every direct implementer add a member; five more would beworse, and the factory hands back a
DaqifiDevice, so consumers reach all of this anyway.Interaction with #341 (cancellable async connect/disconnect +
IAsyncDisposable, beingimplemented separately). Nothing here changes
Connect(),Disconnect(),Dispose()orIStreamTransport.ConnectAsync— the loop is built on the API as it stands. It already takes aCancellationTokenthroughout and unwinds at documented checkpoints, so when #341 lands the loop'sinternal
ConnectCore()/DisconnectCore()calls becomeawaits of the async versions, andDisposeAsyncgets to await the loop rather than leaving it to notice on its own. TheCancelReconnect()/ session-epoch handshake is what makes that a swap rather than a redesign.Interaction with #342 (per-device operation serialization). Not solved here, and not attempted.
What the loop gets for free: re-initialization runs through
ExecuteTextCommandAsync, which takesthe same device-wide text-exchange lock every user SCPI operation takes, so a reconnect cannot
interleave bytes on the wire with a text command — and an operation in flight when the drop happens
is what
Disconnect()'s existing lock wait already covers. What it does not get is exclusion fromfire-and-forget
Send-only calls (EnableChannel,SetDioValue) during the restore window, whichis precisely #342's job. Two things narrow that window meanwhile: the device is not
Connectedformost of a reconnect, so those calls fail fast exactly as they do today after
Lost; and acaller-issued
Connect()orDisconnect()supersedes the loop outright via a session epoch, so theloop can never re-open a transport the caller just closed. When #342 lands, reconnect takes that
lock as a privileged holder and the window closes.
What is deliberately not restored. Only what the library itself owns comes back — the enabled
channel set, the streaming frequency, and an active stream. Everything else is the device's own
state, and after an outage of unknown length Core does not presume to know what it should be: DIO
directions and output levels, PWM, analog outputs, RAM-only calibration, and an SD logging session
are all left alone. Any operation in flight fails — an SD download interrupted by a drop is
neither resumed nor retried; run it again once
Reconnectedfires.Same endpoint only. Reconnection re-opens the endpoint already in use. A serial device that
comes back on a different port path, or one whose IP moved after a reboot, is a new endpoint and
needs a fresh factory connect. Cross-transport failover (USB→WiFi) is explicitly out of scope.
Testing
zero warnings.
reconnects covers resume, retry-until-it-answers, both give-up paths (transport and
initialization), cancellation,
Disconnect()mid-reconnect, disposal mid-reconnect, a throwingsubscriber, a second drop after a recovery, and — byte for byte — that a device with reconnect left
at its default still produces exactly one status transition, to
Lost, and nothing else. One testgoes the long way round, driving real read failures through the production watchdog.
restored with
ResumeStreamingoff reportedIsStreamingfrom before the drop (which also madethe caller's own
StartStreaming()a silent no-op), and a consumer tearing down inside their ownLosthandler got a reconnect started behind them anyway.once with reconnect at its default, once with it enabled and no drop occurring. Identical sample
counts in both modes on both transports (1586 USB, 1585 WiFi), zero reconnect events, zero errors,
and
Disconnect()still reportingDisconnectedrather thanLost. Device still answering on thenetwork afterwards.
Still to verify by hand, since it needs someone to actually touch the hardware: a physical mid-stream
USB unplug and replug, and a real TCP link drop.
closes #379
Not merging — for review. Stacked on #415.
🤖 Generated with Claude Code