fix(device): Send() racing a disconnect now fails typed, not with an NRE - #503
Conversation
…NRE (closes #497) DaqifiDevice.SendNow double-read the mutable _messageProducer field — null-check, then dereference — while StopMessagePumps nulls that field from the disconnect thread under no lock this path takes. A teardown landing between the two reads threw NullReferenceException out of a public API, and the window is not limited to a user-initiated Disconnect(): every auto-reconnect attempt goes through DisconnectCore(Retrying) and nulls the field the same way. Losing the race the other way — field still set, producer already stopped — surfaced a bare InvalidOperationException whose message names an internal lifecycle method ("Call Start() first"), which is exactly the untyped failure DeviceNotConnectedException was introduced to replace in #395. Snapshot the field once (the pattern TextExchangeEngine already documents and uses for the consumer field), and translate a stopped or disposed producer into DeviceNotConnectedException with IsShuttingDown set, keeping the original as InnerException. A DeviceNotConnectedException from the producer is rethrown unchanged, and non-lifecycle failures (an IOException, say) keep their type. This is not an exotic race: the stress test reproduced the raw InvalidOperationException on 5 of 5 pre-fix runs within ~25 reconnect cycles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoFix Send/disconnect race: throw DeviceNotConnectedException instead of NRE/IOE
AI Description
Diagram
High-Level Assessment
Files changed (2)
|
Code Review by Qodo
1.
|
…n stress loop The loop recorded every exception the sender thread saw into a shared list, so a four-second run against a losing race retained thousands of exception instances it never read. Only one of them can fail the test. Keep the first untyped failure via Interlocked.CompareExchange and count the typed ones; the sender now raises the stop flag as soon as something untyped escapes, and the driver loop watches that flag, so a failing run reports in milliseconds instead of cycling a device nobody is sending to for the rest of the budget. The dedicated NullReferenceException assertion is dropped as redundant: an NRE is untyped, so it lands in the same check, which names the type it caught. Still catches the bug it was written for — with the translation reverted, the raw InvalidOperationException reproduced on 5 of 5 runs, now in ~45ms each. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Qodo-clean, CI green — ready for review. (Head |
|
Code review by qodo was updated up to the latest commit 32ea856 |
Closes #497.
Not merging — this is for your review.
What was wrong
Disconnect a device while anything was still sending to it and
Sendcould throw aNullReferenceExceptionstraight out of a public API. Lose the same race a fraction later and you instead got a bareInvalidOperationExceptionreading "Message producer is not running. Call Start() first." — an internal lifecycle detail, not something a caller can act on.Either way you did not get
DeviceNotConnectedException, which is the exception this library tells you to catch for exactly this situation. So an ordinary shutdown surfaced as a confusing crash, and there was no way to tell it apart from a real defect in your own code without matching on message text.It was also not limited to you calling
Disconnect(). Every auto-reconnect attempt tears the send path down the same way, so any long-lived sender — a telemetry loop, a UI poll — was exposed on every reconnect, including ones it never asked for.How it was fixed
SendNownow reads the message producer once into a local, instead of null-checking the field and then dereferencing it a second time. That closes theNullReferenceExceptionwindow by construction. If teardown gets to the producer after that read, the resulting failure is translated intoDeviceNotConnectedExceptionwithIsShuttingDownset — so a shutdown race reports as a shutdown race.Things you may want to push back on:
IOException, say) keep their own type, and aDeviceNotConnectedExceptionfrom the producer is rethrown untouched, so this cannot mask a real fault — but it does depend on the producer's choice of exception type. Tests pin that against the realMessageProducer<string>, so a change there fails here rather than silently escaping.ObjectDisposedExceptionmust be caught beforeInvalidOperationException, since it derives from it. That ordering is load-bearing and a test pins it, but it is the kind of thing a future edit can quietly break.SendViaProducerisinternal, not private, purely so the translation can be driven deterministically in tests. Same seam the registry already uses forDeviceConnector.Verification
InvalidOperationExceptionon 5 of 5 runs, within ~25 reconnect cycles each. TheNullReferenceExceptionhalf is a far narrower window, so the stress test asserts the negative (nothing untyped escapes) rather than claiming to schedule that race; the snapshot is correct by inspection.DaqifiDeviceSendDisconnectRaceTests.cs, driving every branch of the translation against both a throwing double and a realMessageProducer<string>(stopped and disposed), plus the reconnect stress loop and a happy-path guard./dev/cu.usbmodem1101(fw 3.7.2), non-destructive: two full connect → configure → stream → disconnect cycles, exit 0, 396 samples each (the expected count at this unit's known 79.4% clock ratio). No regression on the common path.🤖 Generated with Claude Code