Skip to content

fix(diagnostics): tell an empty system log apart from a device that never answered - #552

Merged
cptkoolbeenz merged 2 commits into
mainfrom
fix/543-log-terminator
Aug 20, 2026
Merged

cptkoolbeenz merged 2 commits into
mainfrom
fix/543-log-terminator

Conversation

@cptkoolbeenz

Copy link
Copy Markdown
Member

Fixes #543.

The defect

GetSystemLogAsync returned an empty list for two unrelated situations:

  • the device answered, and its log buffer is genuinely empty;
  • the device did not answer at all.

A caller could not tell which. So a silent link, a wedged text exchange, or an unsupported header on below-floor firmware all presented as "your log is empty" — the least useful answer a diagnostics call can give, because it is indistinguishable from a clean bill of health, on the one call an operator reaches for when they suspect the device is unwell.

The signal was already there

The firmware terminates every SYSTem:LOG? dump with a blank line, whether or not it had anything to say. From #543's bench work (Nq1 on firmware 3.7.2, raw pyserial probe, no Core in the path, three trials of three):

SYSTem:LOG?  (empty)      ->  b'\r\n'                        6 ms
SYSTem:LOG?  (populated)  ->  b'Test error message\r\n\r\n'

Since #538 the exchange already parses that blank line internally — it just filtered it out before any caller saw it. So any line reaching the diagnostics layer means the device answered, and zero lines means it did not.

No extra command, and specifically no SYSTem:ERRor? pairing, which would pop an entry off the device's error queue as a side effect of a read — a poor thing for a diagnostics call to do when GetSystemErrorCountAsync sits on the same interface.

Why a parameter and not a new method

keepBlankLines is threaded through the existing ExecuteTextCommandAsync rather than added as a parallel method — the convention this seam already documents:

"a parallel method would be bypassed silently by any subclass that overrides only this one … Overriders must widen their signature — a compile error, which is the point."

That is most of this diff: 22 test doubles gain one parameter. It is also load-bearing rather than incidental — the diagnostics tests inject their canned responses by overriding that virtual, so a new seam would bypass their injection and the behaviour could not be unit-tested at all. I checked ExecuteRawCaptureAsync as an alternative first; using it would mean reimplementing line reading, timeouts and consumer swapping in the diagnostics op, which is worse.

Only the Action overload is widened — the 8 overrides of the async overload are untouched.

A test that encoded the wrong belief

GetSystemLogAsync_WhenBufferEmpty_ReturnsEmpty canned nothing and asserted no-throw, commented "No lines = genuinely empty buffer (firmware writes nothing)".

That premise is false, and it is precisely what made the two states indistinguishable — the test asserted the ambiguity as correct. It now cans a blank line, which is what the device actually sends, and two cases join it:

test asserts
WhenBufferEmpty_ReturnsEmpty a blank line → empty list, no throw
WhenDeviceAnswersNothingAtAll_Throws zero lines → DeviceDiagnosticsException naming the condition
WhenBufferHasEntries_IgnoresTheTerminator a populated dump does not leak its terminator as a phantom log entry

Behaviour change — worth a release note

A caller polling the log on a flaky link previously got an empty list and now gets a throw. That is the point of the issue, but it is visible. The exception is a DeviceDiagnosticsException (not a new type), so catch blocks that already handle diagnostics failures keep working.

Test plan

  • Baseline-checked, not assumed. main fails 4 tests on this station before any change — all Device.Discovery.LinuxUsbPortDescriptorProviderTests, a WSL/Windows runtime artefact, not related to this work.
  • After this change: the same 4, with passing tests up from 3531 → 3533 (the two new cases) and total 3537 → 3539.
  • Daqifi.Mcp.Tests builds clean (its DeviceDouble is one of the widened doubles).
  • No hardware needed — the mechanism is pinned by unit tests against the real response shapes.

The issue's open questions, answered: it is scoped to SYSTem:LOG? only. GetCommandHistoryAsync is untouched — it has a "No command history" marker and is already distinguishable, exactly as the issue suspected.

🤖 Generated with Claude Code

…ever answered

GetSystemLogAsync returned an empty list for two unrelated situations: the
device answered and its log buffer is genuinely empty, and the device did
not answer at all. A caller could not tell which -- so a silent link, a
wedged text exchange, or an unsupported header on below-floor firmware all
presented as "your log is empty".

That is the least useful answer a DIAGNOSTICS call can give, because it is
indistinguishable from a clean bill of health, on the one call an operator
reaches for when they suspect the device is unwell.

THE SIGNAL WAS ALREADY THERE.

The firmware terminates every SYSTem:LOG? dump with a blank line whether or
not it had anything to say -- measured on a bench Nq1 running 3.7.2 with a
raw pyserial probe, three trials of three: an empty log answers b'\r\n' in
6 ms, a populated one answers its entries followed by the same trailing
b'\r\n' (issue #543). Since #538 the exchange already parses that blank
line internally; it filtered it out before any caller saw it.

So ANY line reaching the diagnostics layer -- blank or not -- means the
device answered, and zero lines means it did not. No extra command, and in
particular no SYSTem:ERRor? pairing, which would pop an entry off the
device's error queue as a side effect of a read.

WHY A PARAMETER RATHER THAN A NEW SEAM.

keepBlankLines is threaded through the existing ExecuteTextCommandAsync
rather than added as a parallel method, which is the convention this seam
already documents: "a parallel method would be bypassed silently by any
subclass that overrides only this one ... Overriders must widen their
signature -- a compile error, which is the point."

That is most of this diff: 22 test doubles gain one parameter. It also
happens to be load-bearing rather than incidental -- the diagnostics tests
inject their canned responses by overriding that virtual, so a new seam
would bypass their injection entirely and the behaviour could not be
unit-tested at all.

A TEST THAT ENCODED THE WRONG BELIEF.

GetSystemLogAsync_WhenBufferEmpty_ReturnsEmpty canned NOTHING and asserted
no-throw, commented "No lines = genuinely empty buffer (firmware writes
nothing)". That premise is false, and it is exactly what made the two
states indistinguishable. It now cans a blank line, which is what the
device actually sends, and two cases join it: zero lines throws, and a
populated dump does not leak its terminator into the parsed entries as a
phantom log line.

Baseline-checked rather than assumed: main fails 4 tests here before any
change (Device.Discovery.LinuxUsbPortDescriptorProviderTests, a WSL/Windows
runtime artefact). After this change: the same 4, with passing tests up
from 3531 to 3533.

Fixes #543
@cptkoolbeenz
cptkoolbeenz requested a review from a team as a code owner August 20, 2026 04:54
@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

Distinguish empty system logs from unresponsive devices

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Preserve system-log terminators to distinguish empty logs from silent devices.
• Throw diagnostics errors when SYSTem:LOG? returns no lines.
• Cover empty, unresponsive, and populated log responses.
Diagram

graph TD
  A["Diagnostics API"] --> B["Operation Host"] --> C["Text Exchange"] --> D["System Log"] --> E{"Any line?"}
  E -- "No" --> F["Diagnostics Error"]
  E -- "Yes" --> G["Remove blanks"] --> H["Parse entries"]
Loading
High-Level Assessment

The existing optional parameter is the best fit because it preserves default behavior while forcing virtual overrides and test doubles to acknowledge the widened seam. A parallel method could bypass existing overrides, while raw capture would duplicate line framing, timeout, locking, and consumer-management behavior.

Files changed (20) +153 / -44

Bug fix (5) +66 / -18
DaqifiDevice.csExpose optional blank-line preservation on text exchanges +18/-7

Expose optional blank-line preservation on text exchanges

• Adds 'keepBlankLines' to the overridable action-based text-command method and forwards it into the exchange engine. Documentation explains why system-log diagnostics uniquely require raw blank lines.

src/Daqifi.Core/Device/DaqifiDevice.cs

DaqifiStreamingDevice.csForward blank-line behavior through the operation host +4/-2

Forward blank-line behavior through the operation host

• Updates the explicit operation-host implementation to pass 'keepBlankLines' into the device text-command seam.

src/Daqifi.Core/Device/DaqifiStreamingDevice.cs

DeviceDiagnosticsOperations.csDetect silent system-log responses +27/-2

Detect silent system-log responses

• Requests preserved blank lines when reading the system log and throws 'DeviceDiagnosticsException' if no line arrives. Blank terminators are removed before normal log parsing so they never appear as entries.

src/Daqifi.Core/Device/Diagnostics/DeviceDiagnosticsOperations.cs

IDeviceOperationHost.csExtend the text-command host contract +3/-2

Extend the text-command host contract

• Adds the defaulted 'keepBlankLines' option to the internal operation-host interface and updates its documentation reference.

src/Daqifi.Core/Device/Internal/IDeviceOperationHost.cs

TextExchangeEngine.csOptionally retain collected blank lines +14/-5

Optionally retain collected blank lines

• Adds a defaulted exchange option that returns blank lines after stale-line removal. Existing callers retain the previous filtering behavior unless they explicitly opt in.

src/Daqifi.Core/Device/Internal/TextExchangeEngine.cs

Tests (14) +85 / -24
DaqifiDeviceCapabilityDocumentTests.csUpdate capability test device exchange override +2/-1

Update capability test device exchange override

• Adds the optional blank-line preservation parameter to the test override so it remains compatible with the widened virtual seam.

src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs

DaqifiDeviceDrainErrorQueueTests.csUpdate error-queue test exchange override +2/-1

Update error-queue test exchange override

• Extends the test device override with the new optional 'keepBlankLines' argument without changing existing queue behavior.

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

DaqifiDeviceInitializeTests.csUpdate initialization test exchange overrides +8/-4

Update initialization test exchange overrides

• Adds the blank-line option to four initialization test doubles so their overrides continue matching the production signature.

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

DaqifiDeviceStaleTextLineTests.csUpdate stale-line test exchange override +2/-1

Update stale-line test exchange override

• Widens the stale-line test device override with the optional blank-line preservation parameter.

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

DaqifiStreamingDeviceAnalogOutputTests.csUpdate analog-output test exchange override +2/-1

Update analog-output test exchange override

• Adds 'keepBlankLines' to the streaming device test double while preserving its existing command behavior.

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

DeviceDiagnosticsTests.csTest empty logs separately from silent devices +43/-3

Test empty logs separately from silent devices

• Corrects the empty-log fixture to include the firmware's blank terminator. Adds coverage for no-response failures and verifies populated logs discard the terminator before parsing.

src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs

GetLanChipInfoAsyncTests.csUpdate LAN chip test exchange override +2/-1

Update LAN chip test exchange override

• Adds the optional blank-line argument to the LAN chip information test double for signature compatibility.

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

ConnectionGuardTests.csUpdate connection guard host stub +2/-1

Update connection guard host stub

• Extends the operation-host stub with the new optional 'keepBlankLines' parameter.

src/Daqifi.Core.Tests/Device/Internal/ConnectionGuardTests.cs

DeviceAdministrationOperationsTests.csUpdate administration operation host stub +2/-1

Update administration operation host stub

• Widens the fake host text-command method to match the updated operation-host interface.

src/Daqifi.Core.Tests/Device/Internal/DeviceAdministrationOperationsTests.cs

LiveSampleStreamTests.csUpdate live-stream operation host stub +2/-1

Update live-stream operation host stub

• Adds the optional blank-line parameter to the live sample stream test host.

src/Daqifi.Core.Tests/Device/Internal/LiveSampleStreamTests.cs

StreamFrameDecoderTests.csUpdate frame-decoder operation host stub +2/-1

Update frame-decoder operation host stub

• Updates the frame decoder's test host implementation for the widened text-command contract.

src/Daqifi.Core.Tests/Device/Internal/StreamFrameDecoderTests.cs

SdCardOperationsCollaboratorTests.csUpdate SD collaborator exchange stub +2/-1

Update SD collaborator exchange stub

• Adds the optional blank-line preservation argument to the SD card collaborator test host.

src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsCollaboratorTests.cs

SdCardOperationsTests.csUpdate SD card test exchange overrides +12/-6

Update SD card test exchange overrides

• Widens six SD card test device overrides to remain compatible with the updated virtual text-command seam.

src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs

DeviceDouble.csUpdate MCP device test double +2/-1

Update MCP device test double

• Adds the optional blank-line preservation parameter to the MCP test device override.

src/Daqifi.Mcp.Tests/DeviceDouble.cs

Documentation (1) +2 / -2
SdCardOperations.csRefresh text-exchange documentation references +2/-2

Refresh text-exchange documentation references

• Updates SD card operation documentation links to reference the widened text-command method signature.

src/Daqifi.Core/Device/SdCard/SdCardOperations.cs

@qodo-code-review

Copy link
Copy Markdown

PR Code Suggestions ✨

Warning

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

No code suggestions found for the PR.

I mutation-tested the three tests added for #543 instead of assuming they
earned their place, and one did not.

GetSystemLogAsync_WhenBufferHasEntries_IgnoresTheTerminator asserted that a
populated dump does not leak its trailing blank line into the parsed
entries. Removing the filter it was meant to guard changes nothing: the
test still passes. SystemLogParser.Parse ignores blank lines, and
ScpiResponseClassifier.IsErrorOnlyResponse skips them explicitly via
IsNullOrWhiteSpace, so the terminator could never have become an entry
whether the filter ran or not.

That makes it an assertion that cannot fail. Removed rather than left in
to look like coverage.

The other two are load-bearing and proven so:

  WhenDeviceAnswersNothingAtAll_Throws fails against the pre-change
    implementation -- it is what the fix is for.
  WhenBufferEmpty_ReturnsEmpty passes before AND after, so it is a guard
    rather than a proof, but it is a real guard: mutating the throw to fire
    when every line is blank (rather than when no line arrived) makes it
    fail, which is the over-eager fix it exists to prevent.

The filter itself stays, with its comment corrected to say it is belt and
braces rather than implying it is load-bearing. It keeps the seam's
contract -- everything downstream sees the same content lines it saw before
keepBlankLines existed -- true by construction instead of by depending on
two other components' internals.
@cptkoolbeenz

Copy link
Copy Markdown
Member Author

Mutation-tested my own tests, and removed one that could not fail

Rather than assume the three new tests earned their place, I mutated both the pre-change implementation and the fix to see which assertions actually move.

test fails on pre-change code? catches a mutation of the fix? verdict
WhenDeviceAnswersNothingAtAll_Throws yes — proves the fix
WhenBufferEmpty_ReturnsEmpty no yes genuine guard
WhenBufferHasEntries_IgnoresTheTerminator no no vacuous — removed

The removed one asserted something that was never possible. It checked that a populated dump does not leak its trailing blank line into the parsed entries. But SystemLogParser.Parse ignores blank lines, and ScpiResponseClassifier.IsErrorOnlyResponse skips them explicitly via IsNullOrWhiteSpace — so the terminator could not have become an entry whether the filter ran or not. Deleting the filter line entirely leaves all tests green. That is coverage-shaped nothing, so it is gone rather than left in to pad the count.

The second one is a guard, not a proof, and I checked it is a real guard. It passes before and after, so it does not demonstrate the fix — but mutating the throw from "no line arrived" to "every line is blank" makes it fail. That is exactly the over-eager version of this fix (throwing on a healthy device whose log is simply empty), so the test is worth its place.

Consequence for the filter itself: raw.Where(line => line.Length > 0) is redundant, and its comment now says so instead of implying otherwise. It stays because it keeps this seam's contract — everything downstream sees the same content lines it saw before keepBlankLines existed — true by construction rather than by depending on two other components' internals. But a reader should not mistake it for load-bearing, and now won't.

Revised counts: 4 pre-existing failures unchanged, passing 3531 → 3532, total 3537 → 3538.

This is the same check I asked the adversarial audit to run as item (d). It found it first.

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Remediation recommended

1. Late terminator masks silence 🐞 Bug ☼ Reliability
Description
A blank line arriving after staleLineCount is captured but before the new command is sent is
retained as the current response, so GetSystemLogAsync bypasses its no-answer exception and
reports an empty log for a silent command. This violates the new diagnostic distinction in a
timing-dependent stale-response case.
Code

src/Daqifi.Core/Device/Internal/TextExchangeEngine.cs[R558-560]

+                    result = (keepBlankLines
+                                ? afterStale
+                                : afterStale.Where(line => line.Length > 0))
Relevance

●●● Strong

Recent accepted precedent (#542) fixed same-class stale-line leakage; team treats this as a real bug
to fix.

PR-#542
PR-#400

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The consumer can append lines concurrently at TextExchangeEngine.cs:418-423, while the stale count
is fixed before the setup callback sends the new command at lines 447-466. A blank arriving in that
interval is therefore included by the changed keepBlankLines projection, and GetSystemLogAsync
only checks whether the resulting raw collection is nonempty before filtering the blank away.

src/Daqifi.Core/Device/Internal/TextExchangeEngine.cs[418-423]
src/Daqifi.Core/Device/Internal/TextExchangeEngine.cs[447-466]
src/Daqifi.Core/Device/Internal/TextExchangeEngine.cs[550-561]
src/Daqifi.Core/Device/Diagnostics/DeviceDiagnosticsOperations.cs[65-81]

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 delayed blank terminator from an earlier exchange can arrive after the stale-line snapshot but before the current command is sent. Because `keepBlankLines` now preserves that line, diagnostics treats it as proof that the current `SYSTem:LOG?` answered and returns an empty log even when the new command receives no response.

## Issue Context
The consumer callback appends concurrently. The stale boundary is captured immediately before invoking the setup/send callback, leaving a window in which old input is classified as current input. Strengthen the pre-command drain/boundary mechanism so input attributable to an earlier exchange cannot satisfy the current exchange; preserve immediate legitimate replies from the newly sent command.

## Fix Focus Areas
- src/Daqifi.Core/Device/Internal/TextExchangeEngine.cs[418-466]
- src/Daqifi.Core/Device/Internal/TextExchangeEngine.cs[478-510]
- src/Daqifi.Core/Device/Internal/TextExchangeEngine.cs[538-561]
- src/Daqifi.Core.Tests/Device/DaqifiDeviceStaleTextLineTests.cs[1-430]

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


Grey Divider

Context sources
Review mode: ⚖️ Balanced: This behavioral diagnostics change modifies a shared text-exchange seam and many overrides, with meaningful risk across response parsing and device-operation paths, but its logic is cohesive rather than defect-dense enough to justify extended 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

Qodo Logo

Comment on lines +558 to +560
result = (keepBlankLines
? afterStale
: afterStale.Where(line => line.Length > 0))

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 terminator masks silence 🐞 Bug ☼ Reliability

A blank line arriving after staleLineCount is captured but before the new command is sent is
retained as the current response, so GetSystemLogAsync bypasses its no-answer exception and
reports an empty log for a silent command. This violates the new diagnostic distinction in a
timing-dependent stale-response case.
Agent Prompt
## Issue description
A delayed blank terminator from an earlier exchange can arrive after the stale-line snapshot but before the current command is sent. Because `keepBlankLines` now preserves that line, diagnostics treats it as proof that the current `SYSTem:LOG?` answered and returns an empty log even when the new command receives no response.

## Issue Context
The consumer callback appends concurrently. The stale boundary is captured immediately before invoking the setup/send callback, leaving a window in which old input is classified as current input. Strengthen the pre-command drain/boundary mechanism so input attributable to an earlier exchange cannot satisfy the current exchange; preserve immediate legitimate replies from the newly sent command.

## Fix Focus Areas
- src/Daqifi.Core/Device/Internal/TextExchangeEngine.cs[418-466]
- src/Daqifi.Core/Device/Internal/TextExchangeEngine.cs[478-510]
- src/Daqifi.Core/Device/Internal/TextExchangeEngine.cs[538-561]
- src/Daqifi.Core.Tests/Device/DaqifiDeviceStaleTextLineTests.cs[1-430]

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

@cptkoolbeenz

Copy link
Copy Markdown
Member Author

Adversarial audit: gate: PASS — the one finding was refuted on a before/after trace

Qodo raised one bug, "late terminator masks silence": a stale blank arriving between the stale-line boundary capture and the send would be counted as this exchange's terminator, so GetSystemLogAsync would report an empty log for a silent device.

The mechanism is real. The regression is not. The audit traced both origin/main and this head (da8c6a3) and found identical behaviour in that exact race: on main, EmitEmptyLines = true already lets such a late blank satisfy hasReceivedAny, and the pre-existing .Where(line => line.Length > 0) filter reduces it to an empty list — which SystemLogParser.Parse and ThrowIfErrorOnlyResponse already treat as "no entries, don't throw". Same input, same output, before and after. It is the staleLineCount heuristic's documented gap (the #396 comment at the capture site) and it applies to every engine caller, not just this one.

I tried to fix it anyway, then reverted — and the reason is the interesting part

I did not want to decline a bug I could fix, so I implemented a second boundary captured immediately after setupActionAsync returns, used only to reject blanks (a device cannot emit a terminator for a command it has not been sent; content lines kept the wider boundary so nothing else changed). It built cleanly, and I added two engine-level tests using the existing ReleaseOnStreamAccessMockTransport harness.

Then I mutation-tested them, and both passed with the fix removed.

The existing staleLineCount skip already handles a blank released at consumer-bind — that is before the capture. The genuine window is the sub-millisecond interval between the capture and the send completing, and the harness cannot target it: ReleaseOnStreamAccess(n) keys off stream accesses, not off that interval.

So I could not demonstrate the fix changed any reachable behaviour. Shipping an unfalsifiable change plus two tests that cannot fail is worse than a documented limitation, so it is reverted — this branch is back to exactly the audited head.

Filed as #553 with the reproduction gap, why the naive fix is untestable today, and the two decisions a real fix needs (a seam that can reach the window; whether narrowing the content-line boundary risks dropping a fast genuine reply — a bench question, not a reasoning one).

Final state

check result
adversarial audit PASS, treadmill: false, 1 raw finding / 1 refuted / 0 confirmed
/improve 0 suggestions
/agentic_review 1 bug, refuted above and tracked as #553
tests 4 pre-existing failures (unchanged), passing 3531 → 3532
tests that earn their place 1 fails on pre-change code; 1 guard proven by mutating the fix; 1 removed as vacuous

Merging.

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

Labels

None yet

Projects

None yet

1 participant