fix(device): make background failures visible and stop the last silent read-loop spin (closes #377, #394, #378) - #415
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>
|
/agentic_review |
PR Summary by QodoSurface device background failures via ErrorOccurred + decode counter; stop unreadable spin
AI Description
Diagram
High-Level Assessment
Files changed (12)
|
Code Review by Qodo
1.
|
…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>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 3908260 |
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>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 4b19d59 |
…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>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit aebb59d |
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>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 048d29e |
Resolves against #415 (connection-loss detection and the device ErrorOccurred surface) and #417 (SD->LAN restore inside the exchange lock, which added a finalizeAsync phase to ExecuteTextCommandAsync). One real conflict, in DaqifiDeviceInitializeTests: #417 wrapped the testable device's ExecuteTextCommandAsync body in try/finally to honor the new finalize phase and re-indented it, while this branch had inserted a MutateDuringInitialization hook between the prepare phase and setupAction. Kept both — the hook now sits inside the new try block, still after prepare and before setupAction, so the two overlapping-initialization tests still mutate state at the intended point. The two test doubles this branch added (OverlappingInitDevice and CancelDuringCapabilityReadDevice) also had to widen their ExecuteTextCommandAsync overrides for the finalizeAsync parameter, and now honor the finalize phase the way the other doubles do. That compile break is the seam from #406 working as designed. Verified nothing from main was dropped: the only deletions relative to origin/main are this branch's three intended OnDeviceInitializingAsync signature changes. #415's ErrorOccurred wiring, OnConsumerErrorOccurred subscription and the Connected->Lost transition, and #417's finalizeAsync phase are all intact, as are this branch's PreserveActiveStream command skipping and the pre-Ready cancellation guard. Full suite green on net9.0 and net10.0 (2246 Core + 23 MCP).
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>
Why
When a DAQiFi device stops sending data, the library used to give you nothing to go on. A cable
pull, a dead WiFi link, or a decoder that fails on every frame all looked identical from the
outside: no samples, no error, no status change. Apps had no way to tell "the connection died"
from "the device is quiet", and no way to answer "why am I getting no samples".
Most of the detection landed in #403. This finishes the job by adding the missing piece — a place
for those failures to actually show up — and by closing the one path where the reader could still
spin forever in silence.
What
ErrorOccurredevent on every device. Failures on the background threads — a failedread, an unparseable frame, a subscriber that threw, a frame that wouldn't decode — now reach
your code, tagged with which stage failed. They also go to the logger, so they're visible even
with nobody subscribed.
DaqifiStreamingDevice.DecodeFailureCounttells you how manyframes were dropped in the current streaming session. Zero on a healthy stream.
escalated instead of being retried forever with nothing logged.
docs/DEVICE_INTERFACES.md.The event is diagnostics only. It never tears anything down, never retries, and never changes
connection status. A single bad frame is still dropped on its own without disturbing the stream,
exactly as before. Declaring a connection actually dead stays the transports' job and still arrives
as
ConnectionStatus.Lost.How
A broken thing usually breaks over and over — thousands of times a second at high sample rates — so
events are collapsed rather than fired for every occurrence. The first failure of a given kind is
reported immediately; after that, the same kind is reported at most once every five seconds, and
each report says how many were folded into it. A different kind of failure never waits behind an
ongoing storm. Reconnecting resets this, so a fresh session always reports its first problem right
away.
Handlers run on a background thread, and one that throws is caught and ignored — it can't disturb
reading or streaming.
Note for implementers
IDevicegained an event, so anything implementing that interface directly (test doubles, mocks)needs to add it.
DaqifiDeviceand its subclasses are unaffected.Testing
with zero warnings.
observable while the stream survives, the throttle under a 5000-failure storm, an unreadable
stream being escalated, and an intentional disconnect still never reporting
Lost.events, zero decode failures, no spurious
Lost, andDisconnect()reportingDisconnected.TCP keep-alive did not disturb WiFi connect or streaming.
Still to verify by hand: a physical mid-stream cable pull (someone has to actually unplug it).
closes #377
closes #394
closes #378
Not merging — for review.
🤖 Generated with Claude Code