Skip to content

fix(device): give health telemetry a way to refresh, and stop the docs claiming it is live - #558

Merged
cptkoolbeenz merged 4 commits into
mainfrom
fix/535-health-refresh
Aug 20, 2026
Merged

cptkoolbeenz merged 4 commits into
mainfrom
fix/535-health-refresh

Conversation

@cptkoolbeenz

Copy link
Copy Markdown
Member

Fixes #535.

device.Metadata.Health is documented as live telemetry. It is a snapshot taken once at connect that never changes again for the life of the connection. Build a UI on it and it shows "Battery: 87%" an hour later on a device that has since run flat.

Two independent causes, both confirmed on hardware

1. Nothing ever asks again. The only GetDeviceInfo sends are inside WaitForChannelsPopulatedAsync, which stops the moment channels populate during InitializeAsync. There was no public API to ask afterwards.

2. The firmware sends no periodic status frames on a connected link. Each stream frame is built from four tags and none of them is health, so a connected device is silent unless asked. From the issue's bench run (Nq1, fw 3.7.2):

window duration stream frames status frames
idle 40 s 0 0
streaming @ 100 Hz 20 s 1,586 0
after one explicit GetDeviceInfo 2.5 s — 1

1,587 inbound messages; exactly one carried health, and only because it was requested.

The fix

RefreshDeviceStatusAsync(TimeSpan?, CancellationToken) — sends GetDeviceInfo, waits for the next status message, returns once it has been applied. Additive, non-breaking.

It subscribes before sending, so a fast reply cannot slip through the gap between Send() and the subscription — the same ordering WaitForChannelsPopulatedAsync already relies on. It unsubscribes in a finally, so a caller polling in a loop does not accumulate one handler per call for the life of the connection. Default timeout 5 s: a device that is answering at all replies in milliseconds, so this is a hang detector, not a prediction.

Cadence stays with the caller, deliberately. The issue flagged this and it is right — a self-poll inside Core would contend for the operation lock against streaming and SD work, and how often "current" needs to be is a decision only the application can make.

Docs corrected — both claims were wrong

DeviceHealth said its values update "including the periodic ones emitted during streaming". There are none. Worth noting the original ticket #335 hedged this correctly as "if present"; the hedge was dropped when the doc was written.

BoardTemperatureCelsius now says what it is: no shipping firmware populates it. The encoder's temp_status case is empty, so the field is never assigned, stays 0, and proto3 omits it — checked at v3.5.0, v3.6.0, v3.7.0, v3.7.1 and v3.7.2, i.e. the entire range Core supports. Against real hardware it cannot be anything but null. Kept, because the wire field exists and a future firmware may fill it.

Which option this took, and why

The issue offered docs-only or docs-plus-refresh and left it open. The acceptance criteria settle it — they ask for "a supported way to refresh health telemetry" and "a test covering the refresh path", which docs-only cannot satisfy. It is also the smaller surprise: the alternative documents Send(ScpiMessageProducer.GetDeviceInfo) as the refresh path, which works but leaves every consumer to reinvent the wait, the timeout and the unsubscribe.

Test plan

test asserts
CompletesAndAppliesTheReply_WhenAStatusArrives it actually sends SYSInfoPB, and Metadata.Health carries the reply on return
WhenTheDeviceStaysSilent_Throws TimeoutException rather than a hang — the realistic failure, since a lost request produces silence, not an error frame
WhenNotConnected_Throws DeviceNotConnectedException
UnsubscribesAfterCompleting three refreshes leave nothing subscribed; a later unsolicited status still applies and faults nothing

All four fail without the new method — it does not exist to be called. The device double now captures Send rather than transmitting (it has no transport), which also lets the first test assert which command went out rather than merely that the call did not throw.

Baseline-checked: main fails 4 tests on this station before any change (Device.Discovery.LinuxUsbPortDescriptorProviderTests, a WSL/Windows runtime artefact). After: the same 4, passing 3538 → 3542.

No hardware needed; the bench evidence is recorded in the issue.

🤖 Generated with Claude Code

…s claiming it is live

device.Metadata.Health is documented as live telemetry. It is a snapshot
taken once at connect that never changes again for the life of the
connection. A UI built on it shows "Battery: 87%" an hour later on a device
that has since run flat.

Two independent causes, both confirmed on a bench Nq1 running 3.7.2:

  Nothing ever asks again. The only GetDeviceInfo sends are inside
    WaitForChannelsPopulatedAsync, which stops the moment channels populate
    during InitializeAsync, and there was no public API to ask afterwards.

  The firmware sends no periodic status frames on a connected link. Each
    stream frame is built from four tags and none of them is health, so a
    connected device is silent unless asked. Measured: 1,587 inbound
    messages over 65 s, exactly ONE carrying health -- the one the harness
    requested.

ADDS RefreshDeviceStatusAsync(TimeSpan?, CancellationToken): sends
GetDeviceInfo, waits for the next status message, and returns once it has
been applied. Additive and non-breaking. It subscribes before sending so a
fast reply cannot slip through the gap, the same ordering
WaitForChannelsPopulatedAsync already relies on, and it unsubscribes in a
finally so a caller polling in a loop does not accumulate a handler per
call. Default timeout 5 s -- a device that is answering at all replies in
milliseconds, so this is a hang detector rather than a prediction.

Cadence stays with the caller deliberately. A self-poll inside Core would
contend for the operation lock against streaming and SD work, and how often
"current" needs to be is a decision only the application can make.

DOCS CORRECTED. DeviceHealth claimed its values update "including the
periodic ones emitted during streaming". There are none; the original
ticket #335 hedged this as "if present" and the hedge was dropped when the
doc was written. The summary now says status messages are not periodic and
points at the refresh.

BoardTemperatureCelsius now says what it is: no shipping firmware populates
it. The encoder's temp_status case is empty, so the field is never
assigned, stays 0, and proto3 omits it -- checked at v3.5.0, v3.6.0,
v3.7.0, v3.7.1 and v3.7.2, the whole range Core supports. Against real
hardware it cannot be anything but null. Kept because the wire field exists
and a future firmware may fill it.

Four tests: the refresh sends SYSInfoPB and applies the reply; a silent
device times out rather than hanging; not-connected throws; and a caller
refreshing repeatedly leaves nothing subscribed behind. All four fail
without the new method -- it does not exist to be called.

Baseline unchanged: the same 4 pre-existing LinuxUsbPortDescriptorProvider
failures, passing 3538 -> 3542.

Fixes #535
@cptkoolbeenz
cptkoolbeenz requested a review from a team as a code owner August 20, 2026 05:47
@cptkoolbeenz

Copy link
Copy Markdown
Member Author

/improve

@cptkoolbeenz

Copy link
Copy Markdown
Member Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add on-demand refresh for device health telemetry

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Adds caller-controlled refreshes that request and apply current device health telemetry.
• Enforces connection, timeout, cancellation, and handler cleanup across refresh operations.
• Corrects health and temperature documentation and covers success and failure paths.
Diagram

sequenceDiagram
    actor App as Application
    participant Core as DaqifiDevice
    participant FW as Device Firmware
    participant Handler as Status Handler
    participant Meta as Health Metadata
    App->>Core: Refresh status
    Core->>Core: Subscribe before send
    Core->>FW: GetDeviceInfo
    FW-->>Handler: Status message
    Handler->>Meta: Apply telemetry
    Handler-->>Core: Raise status event
    Core-->>App: Complete refresh
    alt No reply before timeout
        Core-->>App: Throw TimeoutException
    end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Library-managed periodic polling
  • ➕ Keeps health metadata fresh without application coordination.
  • ➕ Provides a uniform refresh interval across consumers.
  • ➖ Introduces background traffic and lifecycle management.
  • ➖ Can contend with streaming and SD operations for the device operation lock.
  • ➖ Cannot select a cadence appropriate for every application.

Recommendation: Keep the caller-controlled refresh API. It fixes stale telemetry without introducing background polling or lock contention, while allowing each application to choose its required freshness. Reusing the existing status event and metadata update path also avoids a parallel decoding pipeline.

Files changed (3) +189 / -2

Bug fix (1) +82 / -0
DaqifiDevice.csAdd asynchronous on-demand device status refresh +82/-0

Add asynchronous on-demand device status refresh

• Introduces RefreshDeviceStatusAsync with a configurable five-second default timeout and cancellation support. It subscribes before sending GetDeviceInfo, completes after metadata is updated, validates connection and timeout state, and always removes its temporary event handler.

src/Daqifi.Core/Device/DaqifiDevice.cs

Tests (1) +83 / -0
DaqifiDeviceTests.csCover device status refresh behavior and cleanup +83/-0

Cover device status refresh behavior and cleanup

• Adds tests for successful status application, silent-device timeouts, disconnected calls, and repeated refresh cleanup. Extends the test device to capture outbound commands so tests verify that the device-information query is sent.

src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs

Documentation (1) +24 / -2
DeviceHealth.csCorrect health telemetry freshness and temperature documentation +24/-2

Correct health telemetry freshness and temperature documentation

• Clarifies that health values remain snapshots until an explicit status request is made and directs callers to the new refresh API. Documents that supported shipping firmware does not populate board temperature, leaving the property null until future firmware supplies it.

src/Daqifi.Core/Device/DeviceHealth.cs

@qodo-code-review

qodo-code-review Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

PR Code Suggestions ✨

Warning

/improve is deprecated. Use /agentic_review instead (removal date not yet scheduled).

CategorySuggestion                                                                                                                                    Impact
Possible issue
Serialize concurrent status refreshes

Add a semaphore to serialize concurrent calls to RefreshDeviceStatusAsync. This
ensures each invocation waits for its own distinct response and prevents
overlapping commands.

src/Daqifi.Core/Device/DaqifiDevice.cs [44-90]

+private readonly SemaphoreSlim _statusRefreshGate = new(1, 1);
+
 public async Task RefreshDeviceStatusAsync(
     TimeSpan? timeout = null,
     CancellationToken cancellationToken = default)
 {
-    EnsureConnected(cancellationToken);
-
-    ...
-
-    StatusMessageReceived += OnStatus;
+    await _statusRefreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
     try
     {
-        Send(ScpiMessageProducer.GetDeviceInfo);
+        EnsureConnected(cancellationToken);
 
         ...
 
-        await statusTcs.Task.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
+        StatusMessageReceived += OnStatus;
+        try
+        {
+            Send(ScpiMessageProducer.GetDeviceInfo);
+
+            ...
+
+            await statusTcs.Task.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
+        }
+        finally
+        {
+            StatusMessageReceived -= OnStatus;
+        }
     }
     finally
     {
-        StatusMessageReceived -= OnStatus;
+        _statusRefreshGate.Release();
     }
 }

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 5

__

Why: Serializing concurrent status refreshes prevents overlapping requests and ensures each caller receives a distinct response, though concurrent calls are likely rare in practice.

Low
  • Update

@qodo-code-review

qodo-code-review Bot commented Aug 20, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Subscriber can block refresh ✓ Resolved 🐞 Bug ☼ Reliability
Description
RefreshDeviceStatusAsync appends its completion handler to the public StatusMessageReceived
multicast event, so an earlier subscriber that throws prevents the refresh handler from running.
Metadata is applied, but the refresh still waits until timeout despite receiving the requested
status.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[3348]

+            StatusMessageReceived += OnStatus;
Relevance

●●● Strong

Exact recent precedent accepted isolating classified-event subscriber exceptions.

PR-#323

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The refresh handler is attached after existing consumer handlers. RaiseClassifiedEvent invokes the
complete multicast delegate once inside one try/catch; normal multicast invocation stops at the
first exception, so later handlers are skipped even though the exception is swallowed.

src/Daqifi.Core/Device/DaqifiDevice.cs[3337-3348]
src/Daqifi.Core/Device/DaqifiDevice.cs[3475-3491]
src/Daqifi.Core/Device/DaqifiDevice.cs[3506-3521]
src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs[118-133]
PR-#323

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The refresh completion callback relies on a public multicast event. `RaiseClassifiedEvent` invokes the whole delegate in one `try`, meaning a throwing earlier subscriber prevents later callbacks—including the refresh waiter—from executing.

## Issue Context
Subscriber exceptions are intentionally tolerated by this event path. Isolation must therefore occur per subscriber, or refresh completion must use a separate private notification path that consumer handlers cannot suppress.

## Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[3340-3348]
- src/Daqifi.Core/Device/DaqifiDevice.cs[3506-3521]
- src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs[118-175]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Refresh bypasses operation serialization ✓ Resolved 🐞 Bug ≡ Correctness
Description
Because RefreshDeviceStatusAsync calls fire-and-forget Send outside RunExclusiveAsync, its
request can remain deferred behind a long exclusive operation while its timeout runs, causing a
timeout before the command is transmitted. Concurrent refreshes also overlap, so the first global
status event completes every outstanding call rather than each call waiting for its own request.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R3351-3354]

+                Send(ScpiMessageProducer.GetDeviceInfo);
+
+                using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+                timeoutCts.CancelAfter(wait);
Relevance

●●● Strong

Recent accepted precedents favor serializing concurrent device operations and preventing
deferred-send races.

PR-#421
PR-#295

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The refresh starts waiting immediately after Send, but Send explicitly returns before
transmission and may defer the message until an exclusive operation finishes. Every refresh also
subscribes an unconditional handler to the same device-wide event, so without operation
serialization one status frame completes all concurrent calls.

src/Daqifi.Core/Device/DaqifiDevice.cs[3337-3358]
src/Daqifi.Core/Device/DaqifiDevice.cs[1530-1583]
src/Daqifi.Core/Device/DaqifiDevice.cs[914-982]
src/Daqifi.Core/Device/Internal/OperationSerializer.cs[149-174]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`RefreshDeviceStatusAsync` sends outside the device operation lock. A competing exclusive or text operation can defer the command beyond the refresh timeout, while concurrent refreshes can all complete from one shared status event.

## Issue Context
`Send` is fire-and-forget and defers messages while another operation owns the device. `RunExclusiveAsync` is reentrant, so the complete subscribe/send/wait sequence can safely execute under it and serialize concurrent refreshes.

## Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[3324-3370]
- src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs[155-220]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Late reply satisfies next refresh 🐞 Bug ≡ Correctness ⭐ New
Description
After a refresh times out, the gate is released while its transmitted request may still produce a
delayed response; the next refresh's unfiltered handler accepts that response and returns before its
own reply arrives. The caller can therefore observe stale metadata despite the new serialization
claiming each call waits for its own request.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[3374]

+                void OnStatus(DaqifiOutMessage _) => statusTcs.TrySetResult(true);
Relevance

●●● Strong

Recent accepted precedents explicitly fix stale cross-session replies and reply-corruption races in
DaqifiDevice concurrency paths.

PR-#421
PR-#400

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The handler at line 3374 completes for every status message without identifying which request
produced it. On timeout, lines 3391-3404 unsubscribe and release the gate but do not invalidate the
already-sent request, allowing a subsequent call to subscribe before that request's delayed reply
arrives; the concurrency test only injects replies in ideal request order and does not exercise a
late reply after timeout.

src/Daqifi.Core/Device/DaqifiDevice.cs[3371-3404]
src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs[218-229]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A status response can arrive after its refresh timed out. Because the timeout releases the semaphore and the next call completes on any status event, that delayed response can incorrectly satisfy the next refresh.

## Issue Context
The protocol event currently has no request correlation. Ensure an outstanding response from a timed-out request cannot be consumed as the response to a later refresh; use protocol correlation if available, otherwise quarantine/drain the outstanding response or reset the request channel before allowing another refresh. Add a test where the first call times out, its response arrives during the second call, and the second call must continue waiting for its own response.

## Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[3371-3404]
- src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs[199-233]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Boundary reply becomes timeout ✓ Resolved 🐞 Bug ≡ Correctness
Description
If the status task completes at the same time the timeout token is canceled, WaitAsync may throw
cancellation and the catch unconditionally translates it to TimeoutException. The requested status
can therefore already be applied while the refresh reports failure.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R3358-3362]

+                    await statusTcs.Task.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
+                }
+                catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+                {
+                    throw new TimeoutException(
Relevance

●●● Strong

Exact recent precedent accepted rechecking worker completion at timeout boundaries.

PR-#326

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The catch path tests only whether the caller token requested cancellation; it never rechecks
statusTcs.Task.IsCompleted before throwing. The repository's channel-population wait already
performs this completion recheck at cancellation and timeout boundaries.

src/Daqifi.Core/Device/DaqifiDevice.cs[3353-3364]
src/Daqifi.Core/Device/DaqifiDevice.cs[3440-3458]
PR-#326

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The timeout and status completion can race at the deadline. When cancellation wins `WaitAsync`, the method throws a timeout without checking whether the status task completed in the same window.

## Issue Context
Prefer the received status when completion and timeout occur together; otherwise preserve caller cancellation and translate only the internal timeout cancellation.

## Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[3353-3364]
- src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs[177-188]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: ⚖️ Balanced: The push changes synchronization and event-delivery behavior in a public async device API, with timeout, cancellation, exception-isolation, and lifecycle interactions that merit a careful single-pass review.

Grey Divider

Tip of the day
💡 Did you know, you can copy the agent prompt from any finding and feed it to your IDE agent

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit c9cabd9

Results up to commit f86000b ⚖️ Balanced


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Action required
1. Subscriber can block refresh ✓ Resolved 🐞 Bug ☼ Reliability
Description
RefreshDeviceStatusAsync appends its completion handler to the public StatusMessageReceived
multicast event, so an earlier subscriber that throws prevents the refresh handler from running.
Metadata is applied, but the refresh still waits until timeout despite receiving the requested
status.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[3348]

+            StatusMessageReceived += OnStatus;
Relevance

●●● Strong

Exact recent precedent accepted isolating classified-event subscriber exceptions.

PR-#323

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The refresh handler is attached after existing consumer handlers. RaiseClassifiedEvent invokes the
complete multicast delegate once inside one try/catch; normal multicast invocation stops at the
first exception, so later handlers are skipped even though the exception is swallowed.

src/Daqifi.Core/Device/DaqifiDevice.cs[3337-3348]
src/Daqifi.Core/Device/DaqifiDevice.cs[3475-3491]
src/Daqifi.Core/Device/DaqifiDevice.cs[3506-3521]
src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs[118-133]
PR-#323

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The refresh completion callback relies on a public multicast event. `RaiseClassifiedEvent` invokes the whole delegate in one `try`, meaning a throwing earlier subscriber prevents later callbacks—including the refresh waiter—from executing.

## Issue Context
Subscriber exceptions are intentionally tolerated by this event path. Isolation must therefore occur per subscriber, or refresh completion must use a separate private notification path that consumer handlers cannot suppress.

## Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[3340-3348]
- src/Daqifi.Core/Device/DaqifiDevice.cs[3506-3521]
- src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs[118-175]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Refresh bypasses operation serialization ✓ Resolved 🐞 Bug ≡ Correctness
Description
Because RefreshDeviceStatusAsync calls fire-and-forget Send outside RunExclusiveAsync, its
request can remain deferred behind a long exclusive operation while its timeout runs, causing a
timeout before the command is transmitted. Concurrent refreshes also overlap, so the first global
status event completes every outstanding call rather than each call waiting for its own request.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R3351-3354]

+                Send(ScpiMessageProducer.GetDeviceInfo);
+
+                using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+                timeoutCts.CancelAfter(wait);
Relevance

●●● Strong

Recent accepted precedents favor serializing concurrent device operations and preventing
deferred-send races.

PR-#421
PR-#295

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The refresh starts waiting immediately after Send, but Send explicitly returns before
transmission and may defer the message until an exclusive operation finishes. Every refresh also
subscribes an unconditional handler to the same device-wide event, so without operation
serialization one status frame completes all concurrent calls.

src/Daqifi.Core/Device/DaqifiDevice.cs[3337-3358]
src/Daqifi.Core/Device/DaqifiDevice.cs[1530-1583]
src/Daqifi.Core/Device/DaqifiDevice.cs[914-982]
src/Daqifi.Core/Device/Internal/OperationSerializer.cs[149-174]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`RefreshDeviceStatusAsync` sends outside the device operation lock. A competing exclusive or text operation can defer the command beyond the refresh timeout, while concurrent refreshes can all complete from one shared status event.

## Issue Context
`Send` is fire-and-forget and defers messages while another operation owns the device. `RunExclusiveAsync` is reentrant, so the complete subscribe/send/wait sequence can safely execute under it and serialize concurrent refreshes.

## Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[3324-3370]
- src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs[155-220]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
3. Boundary reply becomes timeout ✓ Resolved 🐞 Bug ≡ Correctness
Description
If the status task completes at the same time the timeout token is canceled, WaitAsync may throw
cancellation and the catch unconditionally translates it to TimeoutException. The requested status
can therefore already be applied while the refresh reports failure.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R3358-3362]

+                    await statusTcs.Task.WaitAsync(timeoutCts.Token).ConfigureAwait(false);
+                }
+                catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+                {
+                    throw new TimeoutException(
Relevance

●●● Strong

Exact recent precedent accepted rechecking worker completion at timeout boundaries.

PR-#326

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The catch path tests only whether the caller token requested cancellation; it never rechecks
statusTcs.Task.IsCompleted before throwing. The repository's channel-population wait already
performs this completion recheck at cancellation and timeout boundaries.

src/Daqifi.Core/Device/DaqifiDevice.cs[3353-3364]
src/Daqifi.Core/Device/DaqifiDevice.cs[3440-3458]
PR-#326

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The timeout and status completion can race at the deadline. When cancellation wins `WaitAsync`, the method throws a timeout without checking whether the status task completed in the same window.

## Issue Context
Prefer the received status when completion and timeout occur together; otherwise preserve caller cancellation and translate only the internal timeout cancellation.

## Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[3353-3364]
- src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs[177-188]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment thread src/Daqifi.Core/Device/DaqifiDevice.cs Resolved
Comment thread src/Daqifi.Core/Device/DaqifiDevice.cs Resolved
Comment thread src/Daqifi.Core/Device/DaqifiDevice.cs Resolved
Two findings from review of the #535 fix. Both are about the same thing:
what a caller is actually promised.

CONCURRENT REFRESHES COULD COMPLETE EACH OTHER. Two calls both subscribed
to the same multicast StatusMessageReceived event, so ONE incoming frame --
which answers only one of the two outstanding requests -- completed both
TaskCompletionSources. The loser returned success off a reply that was
never its own.

Nobody got wrong DATA from this: both callers saw a genuinely fresh
reading, since the reply updates the shared Metadata either way. What was
wrong is the promise. A method whose entire purpose is "ask the device now
and tell me when it answered" should not report an answer to a question it
did not ask, and a caller retrying after its own short timeout while the
first attempt is still in flight is not an exotic usage.

A SemaphoreSlim now serializes them, so each call sends its own request and
completes on its own reply. Both reviewers raised this independently --
Qodo as a suggestion, the audit as an unsound refutation of the
single-refresh analysis -- which is a fair signal that the reasoning
"nobody gets wrong data" was answering the wrong question.

THE TIMEOUT COVERS MORE THAN THE ROUND TRIP. Send is fire-and-forget and
OperationSerializer.TryDeferSend parks it while an exclusive operation
holds the device -- an SD download can hold it for many minutes. So a short
timeout taken during one elapses before the device is ever asked, and the
caller gets a TimeoutException that reads like a silent device.

Left as-is behaviourally: the caller's deadline bounding total time-to-
answer is the defensible reading, and it fails loudly rather than silently.
But it was undocumented, which is the part that would have cost someone an
afternoon. The parameter now says the deadline covers waiting behind
another refresh and waiting for the send to leave the queue, and names the
SD-download case specifically. One deadline now covers the whole operation
rather than only the reply wait.

The concurrency test is mutation-proved: widening the semaphore to
unlimited makes it fail. It asserts the queued caller has not sent yet, is
not completed by the first reply, and then completes on its own.
@cptkoolbeenz

Copy link
Copy Markdown
Member Author

/agentic_review

@cptkoolbeenz

Copy link
Copy Markdown
Member Author

Audit round 1: keep_fixing — one confirmed finding and one unsound refutation. Both addressed.

The interesting outcome is the second one: the arbiter rejected its own skeptic's refutation for only testing the single-refresh case, and sent back the exact scenario my audit brief had asked about as check (a). Qodo independently raised the same thing as a suggestion. Two reviewers converging on it is a fair signal that my reasoning was answering the wrong question.

Concurrent refreshes could complete each other

Two calls to RefreshDeviceStatusAsync both subscribed to the same multicast StatusMessageReceived event, so one incoming frame — which answers only one of the two outstanding requests — completed both TaskCompletionSources. The loser returned success off a reply that was never its own.

I want to be precise about the scope, because it is narrower than it sounds and I nearly used that to dismiss it: no caller ever got wrong data. The reply updates the shared Metadata either way, so both saw a genuinely fresh reading. What was wrong is the promise — a method whose whole purpose is "ask the device now and tell me when it answered" should not report an answer to a question it did not ask. And a caller retrying after its own short timeout while a first attempt is still in flight is ordinary usage, not exotic.

A SemaphoreSlim(1,1) now serializes them: each call sends its own request and completes on its own reply.

Mutation-proved. ConcurrentCalls_AreNotCompletedByEachOthersReply asserts the queued caller has not sent yet, is not completed by the first reply, then sends and completes on its own. Widening the semaphore to SemaphoreSlim(int.MaxValue, int.MaxValue) makes it fail.

The timeout could expire before the request was ever sent

Confirmed, and real: Send is fire-and-forget, and OperationSerializer.TryDeferSend parks it while an exclusive operation holds the device — an SD download can hold it for many minutes. A short timeout taken during one elapses before the device is asked, and the caller gets a TimeoutException that reads like a silent device.

The arbiter offered (a) document that the deadline covers queue-wait, or (b) start the clock only once the send is confirmed transmitted. I took (a), in-PR rather than as a follow-up ticket. Behaviour is unchanged, because the caller's deadline bounding total time to answer is the defensible reading and it fails loudly rather than silently — but it was undocumented, and that is the part that would have cost someone an afternoon. The parameter doc now states the deadline covers waiting behind another refresh and waiting for the send to leave the queue, and names the SD-download case specifically.

One linked CancellationTokenSource now covers the gate wait, the send and the reply wait, so there is a single honest deadline rather than one that silently started before part of the work.

Separately: the baseline is not what I have been calling it

While confirming #531's flake was gone (it is — AllocatesNothing green across 9 runs since #554), a different test failed in 1 of 4 full runs on unmodified main: LiveSampleStreamTests.ConcurrentEnumerations_AllEndOnTheSameDrop. Filed as #559.

That matters for how I have been reporting these PRs: "the baseline is 4 failures" is really "4 deterministic plus at least one intermittent". I spent four runs establishing the fifth one was not mine, which is the cost the ticket exists to save the next person.

Round 2 of the audit is running.

var statusTcs = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);

void OnStatus(DaqifiOutMessage _) => statusTcs.TrySetResult(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Late reply satisfies next refresh 🐞 Bug ≡ Correctness

After a refresh times out, the gate is released while its transmitted request may still produce a
delayed response; the next refresh's unfiltered handler accepts that response and returns before its
own reply arrives. The caller can therefore observe stale metadata despite the new serialization
claiming each call waits for its own request.
Agent Prompt
## Issue description
A status response can arrive after its refresh timed out. Because the timeout releases the semaphore and the next call completes on any status event, that delayed response can incorrectly satisfy the next refresh.

## Issue Context
The protocol event currently has no request correlation. Ensure an outstanding response from a timed-out request cannot be consumed as the response to a later refresh; use protocol correlation if available, otherwise quarantine/drain the outstanding response or reset the request channel before allowing another refresh. Add a test where the first call times out, its response arrives during the second call, and the second call must continue waiting for its own response.

## Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[3371-3404]
- src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs[199-233]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit c52c684

…alling a

delivered reply a timeout

Two more from review, both of which would have told a caller the refresh
FAILED while the status it asked for was already sitting in Metadata --
the most confusing failure this API could produce.

A THROWING SUBSCRIBER COULD BLOCK THE REFRESH. .NET stops invoking a
multicast delegate at the first exception, and RaiseClassifiedEvent wraps
the WHOLE invocation list in one try/catch. That isolates the device from a
faulting subscriber, which is what it was written for, but it does not
isolate subscribers from each other. So a consumer that subscribed to
StatusMessageReceived earlier and throws would prevent the refresh from
ever seeing its own reply.

The waiter no longer rides the public event. It is a private field invoked
directly from OnStatusMessageReceived, before any consumer subscriber and
outside the multicast list, so no third party can delay or prevent it. Its
own faults are contained there for the same reason the classified event
contains a subscriber's. One refresh runs at a time, so a single field is
enough.

The wider problem -- that RaiseClassifiedEvent does not isolate subscribers
from one another for ANY classified event -- is real but bigger than this
PR, and filed separately.

A REPLY AT THE DEADLINE BECAME A TIMEOUT. If the status landed in the same
instant CancelAfter fired, WaitAsync could throw cancellation even though
the reply had arrived and been applied, and the catch translated that
unconditionally into TimeoutException. The wait now checks whether the task
actually completed before reporting failure.

Both are mutation-proved. Routing the waiter back through the public event
makes the throwing-subscriber test fail (it times out, 2 s); the test
otherwise passes.

Baseline unchanged: the same 4 pre-existing LinuxUsbPortDescriptorProvider
failures, passing 3543 -> 3544.
@cptkoolbeenz

Copy link
Copy Markdown
Member Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit cb1c32d

A status frame carries nothing saying which request it answers. So if one
refresh times out and its reply arrives late, the NEXT refresh can be
completed by that older frame rather than its own.

Documented rather than worked around, because there is nothing to work
around it WITH. The obvious heuristic -- skip one frame after a timeout --
makes the commoner case worse: if the earlier reply never arrives at all,
the next refresh would discard its own reply and time out too. MsgTimeStamp
is not a usable correlation key either; nothing in the device layer reads it
for status, and the device-side counter wraps about every 102 seconds.

What the caller actually gets is worth stating plainly, because it is much
less alarming than "wrong answer": a genuine device reading taken before
their call, bounded by the previous refresh's timeout, with their own reply
landing moments later and updating Metadata.Health again.

This is the third finding arising from the same underlying fact, so the
remark names that root cause once -- no request/response correlation --
rather than describing another symptom. The concurrent-refresh case had the
same shape for a different reason and IS prevented outright, by serializing.
@cptkoolbeenz

Copy link
Copy Markdown
Member Author

Round 2 audit: PASS / ready_to_merge. Qodo pass 3: three ✓ Resolved, one new — documented.

Audit round 2 (head c52c684) — clean

0 confirmed findings. It also soundly refuted one item, and the arbiter checked the refutation rather than taking it: RefreshDeviceStatusAsync not being on IStreamingDevice is not an asymmetry this PR introduced — DaqifiDeviceFactory.ConnectTcpAsync/ConnectSerialAsync return the concrete DaqifiStreamingDevice, the documented Quick Start uses var, and every other concrete-only member follows the same pattern — including InitializeAsync, which is what populates Metadata.Health in the first place.

Qodo pass 3 (head cb1c32d)

Subscriber can block refresh, Refresh bypasses operation serialization and Boundary reply becomes timeout are all ✓ Resolved. One new finding, and it is a real one:

A late reply from a timed-out refresh can satisfy the next one. Same shape as the concurrent case, but sequential — and reachable on a merely slow device rather than a silent one.

I have documented it rather than fixed it, because there is nothing to fix it with. A status frame carries nothing identifying which request it answers. I checked MsgTimeStamp as a possible correlation key: nothing in the device layer reads it for status, and the device-side counter wraps roughly every 102 seconds, so it is not usable as one.

The obvious heuristic — skip one frame after a timeout — makes the commoner case worse: if the earlier reply never arrives at all, the next refresh discards its own reply and times out too. Trading a rare mild staleness for a common hard failure is a bad trade, so I did not make it.

Worth stating what the caller actually gets, because it is far less alarming than "wrong answer": a genuine device reading, taken before their call, bounded by the previous refresh's timeout, with their own reply landing moments later and updating Metadata.Health again.

This is the third finding arising from one underlying fact — the protocol has no request/response correlation for status — so the remark names that root cause once rather than cataloguing another symptom. The concurrent case had the same shape for a different reason and is prevented outright, by serializing.

On the last commit and the audit

c9cabd9 is documentation only — one <para> in an XML doc comment, no executable change, tests unchanged at 4 pre-existing failures / 3544 passing. Audit round 3 is running against cb1c32d, the last commit with any behavioural content. I am not spinning a fourth adversarial round over a doc paragraph; that is the treadmill the arbiter warned about two PRs ago, and there is no code in it for an adversary to find a bug in.

@cptkoolbeenz

Copy link
Copy Markdown
Member Author

Audit round 3: PASS, clean, 0 findings. Merging.

Three adversarial rounds and three Qodo passes. Every finding across both was real; none were declined on the merits except the one the protocol makes unfixable, and that is documented rather than waved off.

Final state of the two open counters

suggestions_unresolved=1 — Serialize concurrent status refreshes (importance 5). This is implemented, not outstanding; Qodo simply has not flipped it. Verified in the diff:

511:  private readonly SemaphoreSlim _statusRefreshGate = new(1, 1);
3390:     await _statusRefreshGate.WaitAsync(timeoutCts.Token)
3443:     _statusRefreshGate.Release();

review_bugs=1 — Late reply satisfies next refresh. Documented, with the reason it is not fixed: a status frame carries no identifier for which request it answers, and the obvious heuristic (skip one frame after a timeout) makes the commoner case worse — if the earlier reply never arrives, the next refresh discards its own and times out too.

What each round actually caught

round finding outcome
audit 1 timeout can expire before the request is even sent (parked behind an exclusive op) documented in-PR, behaviour deliberately unchanged
audit 1 unsound refutation — skeptic tested only single-refresh usage concurrent case serialized
audit 2 — PASS; soundly refuted the IStreamingDevice asymmetry
Qodo 2 a throwing subscriber could block the refresh waiter taken off the public multicast event
Qodo 2 a boundary reply became a timeout checks IsCompletedSuccessfully first
Qodo 3 late reply satisfies the next refresh documented — no protocol correlation exists
audit 3 — PASS, clean

Three of those would have told a caller the refresh failed while the status it asked for was already applied — the most confusing thing this API could do. Each fix is mutation-proved; one of my mutation attempts was itself invalid (it did not compile, so it proved nothing) and only surfaced because the run printed no pass/fail line.

Six tests, all of which fail without the code they cover.

Two things filed rather than folded in

Baseline unchanged throughout: the same 4 pre-existing LinuxUsbPortDescriptorProviderTests failures, passing 3538 → 3544.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(device): health telemetry is documented as live but is frozen at connect, and board temperature is never populated by any supported firmware

1 participant