Skip to content

fix(sd): make SD card operations work over the TCP/WiFi transport (closes #327) - #420

Merged
tylerkron merged 3 commits into
mainfrom
fix/327-sd-over-tcp
Aug 2, 2026
Merged

fix(sd): make SD card operations work over the TCP/WiFi transport (closes #327)#420
tylerkron merged 3 commits into
mainfrom
fix/327-sd-over-tcp

Conversation

@tylerkron

@tylerkron tylerkron commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Why

The firmware has served SD card listings and file reads over WiFi since v3.7.0, and desktop apps want to offer SD offload without asking people to plug in a USB cable. Issue #327 asked us to confirm Core can do that.

Most of it already could. The interface prep and the firmware-version gate were made transport-aware in earlier work. What was left was the part nobody had run over a network: the file transfer itself, which was written against a serial port and quietly assumed one.

What

Three things a socket does differently from a serial port, each of which the download got wrong:

A stalled transfer used to hang for half an hour. A serial port has a per-read timeout, so if the device goes quiet the read comes back empty within half a second and we notice. A socket has nothing like that — NetworkStream.ReadAsync ignores the receive timeout and just waits. So a device that stopped part-way through a file left the download parked until the caller's whole 30-minute budget ran out. The receiver now watches for inactivity and gives up after 20 seconds with a message that says what happened and how many bytes made it.

A closed connection was reported as "the device is just quiet". The old code asked the stream whether it was still readable and inferred the rest. A socket keeps reporting itself readable after the other end hangs up, so a dropped connection looked retryable when it wasn't. The download now tells the receiver which transport it is on, because that is the only place the answer exists.

Stopping an SD logging session cut the WiFi link. It re-enabled the LAN interface unconditionally, which restarts the WiFi module. Over USB that is correct and necessary; over WiFi it drops the very connection the command arrived on. It is now USB-only, matching what the interface-restore helper already did.

Plus some tidying found along the way: the download's settle wait now uses the same constant as the other SD operations instead of its own shorter one, and the docs and the logging-start error no longer claim SD operations are impossible over a network.

Nothing public was removed and no existing signature changed. SdCardFileReceiver keeps its original (Stream, int) constructor untouched — it now delegates to a new overload that carries the transport semantics. That distinction matters because Core ships as a NuGet package: adding optional parameters to the existing constructor would have kept every source caller compiling while breaking already-built consumers at runtime. A reflection test pins the old signature, mutation-verified to fail if it is ever widened.

How it was checked

16 new unit tests cover the network cases: 1024-byte chunked delivery, the end-of-file terminator split at every offset of a chunk boundary, throttled delivery, the inactivity window, the zero-length-read classification on both transports, and a full download over a simulated WiFi device. Full suite green — 2,354 tests on net9.0 and net10.0.

Bench: Nyquist 1, firmware 3.7.2

Over USB — everything works, byte for byte.

result
LIST 33 files with sizes and dates
Storage query free 7,799,930,880 / total 7,800,356,864
Download 2,551 bytes, exactly the listed size, sha256[0:16]=9DD0AE41B7FCF5C4, 0.63 s
Content parsed to 232 log entries; end-of-file marker correctly stripped
DELETE file gone, confirmed by re-listing

Over WiFi (192.168.1.30) — the transport works; this firmware build does not finish the job.

  • Storage query returns exactly the same numbers as USB, so SCPI reaches the card and the shared SPI bus is arbitrated correctly with the LAN up.
  • LIST works but comes back short: 16 entries where USB sees 33.
  • Download starts — 51 real file bytes arrive over TCP, so the firmware's reply routing is doing its job — and then the device stops feeding it. Reproduced exactly, twice, including after a reboot with a fresh buffer pool.
  • DELETE returned without error but the file was still there when checked over USB afterwards.

The truncation and the starved transfer are both the known firmware limitation on 3.7.2: the TCP write buffer can shrink to ~1400 B after a streaming session re-partitions the pool, and the SD reply writer gives up on a chunk rather than reporting it. Both are fixed on the firmware main branch after this release (#748 bounds the drains by the runtime buffer size, #750 makes the reply timeout terminal). They are not something Core can paper over.

What this PR does change about that experience: before, the starved WiFi download was a 30-minute freeze with nothing to show for it. Now it is a 20-second, clearly worded failure that reports the 51 bytes it did receive. That is what makes the firmware problem visible instead of looking like a hung app.

Answering the issue's fourth question

Sequencing while a WiFi stream is or was active is already correct and needs no SYSTem:STReam:INTerface handling. The firmware picks where an SD reply goes from the interface the command arrived on (wifi_tcp_server_ContextIsTcp), not from the stream-interface setting, so the two are independent. All four SD operations already stop streaming first. The end-of-listing probe is also safe over TCP: SCPI replies and SD replies go into the same TCP buffer, and the listing command blocks until the listing has been handed over, so the terminator cannot overtake it.

Deliberately left out

  • SD logging over WiFi still refuses. Starting a session disables the LAN to give the card the bus, which cannot work from the other end of a network connection. Only the stop path needed fixing.
  • FormatSdCardAsync has no transport gate. It is destructive and untestable on the bench, and it does not prepare the SD interface on either transport today — worth its own look.
  • The first SD command of every WiFi session answers "No SD Card Detected", then the next command a second later reads the card fine. I tried making the listing retry it and the bench showed it did not help, so that change was dropped rather than shipped unverified. Worth filing against the firmware; the mount clearly loses a race to the shared bus when the LAN stays up.
  • The stream interface is never normalized over WiFi (device init returns early for non-USB). Unrelated to SD reply routing, so out of scope here.

…oses #327)

The SD path was written for USB serial and carried three assumptions that a
socket does not satisfy:

- A file transfer waited for silence that a socket never reports. Serial has a
  per-read timeout; NetworkStream.ReadAsync ignores Socket.ReceiveTimeout and
  simply waits, so a device that stopped answering mid-file parked the download
  for its full 30-minute budget. The receiver now has an inactivity window
  (20 s) and gives up with an accurate, typed failure.

- A zero-length read was classified by asking the stream whether it was still
  readable. NetworkStream.CanRead stays true after the peer's FIN, so a closed
  connection was reported as merely quiet. The caller, which knows its
  transport, now settles it.

- Stopping an SD logging session re-enabled the LAN unconditionally. Over
  WiFi that re-initializes the module and drops the link the command arrived
  on, so it is now USB-only, mirroring PrepareLanInterface.

Also aligns the download's interface-settle wait with the constant the other SD
exchanges use, and corrects the docs and the logging-start message, which still
claimed SD operations were impossible over a network connection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron
tylerkron requested a review from a team as a code owner August 1, 2026 18:30
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix SD downloads over TCP/WiFi with transport-aware stalls and idle timeout

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Make SD file downloads work reliably over TCP/WiFi by detecting stalls and closed sockets.
• Prevent Stop SD logging over WiFi from re-enabling LAN and dropping the active connection.
• Add unit tests covering TCP chunking, EOF splits, throttling, and inactivity timeouts.
Diagram

graph TD
  A["Client app"] --> B["DaqifiStreamingDevice"] --> C["Transport stream"] --> D[("Device firmware")]
  B --> E["SdCardFileReceiver"] --> F["SdCardTransferStalledException"]
  G["Core tests"] --> B
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Transport-level timeout wrapper
  • ➕ Centralizes per-transport read semantics (serial timeout vs socket silence) in one place
  • ➕ Could reduce SdCardFileReceiver constructor surface area
  • ➖ Harder to apply consistently across all raw-capture consumers without broader refactor
  • ➖ May not compose cleanly with existing hard-deadline/cancellation patterns
2. Task.WhenAny per-read idle timeout (no CTS per loop)
  • ➕ Avoids allocating a new CancellationTokenSource each read iteration
  • ➕ Keeps idle timeout logic local to receiver
  • ➖ More error-prone to implement correctly (must handle partial completions and cancellation races)
  • ➖ Still needs careful reasoning to preserve serial semantics and exception typing

Recommendation: Keep the PR’s approach: configuring SdCardFileReceiver with explicit transport semantics (zero-length read meaning and an idle window) is the least invasive change and correctly addresses NetworkStream’s lack of read timeouts. The per-read linked CTS is a reasonable tradeoff for correctness and clarity given the relatively low download throughput and the added unit test coverage.

Files changed (6) +506 / -32

Bug fix (3) +158 / -30
DaqifiStreamingDevice.csMake SD download receiver transport-aware; avoid LAN re-enable over WiFi +49/-14

Make SD download receiver transport-aware; avoid LAN re-enable over WiFi

• Updates SD logging error message and IsUsbConnection docs to reflect that listing/downloading/deleting SD files work over WiFi on supported firmware. Makes StopSdCardLoggingAsync re-enable LAN only on USB. Updates SD download to use the shared interface-settle delay constant, and constructs SdCardFileReceiver with transport-specific zero-length-read semantics plus a configurable idle timeout for TCP stalls.

src/Daqifi.Core/Device/DaqifiStreamingDevice.cs

SdCardFileReceiver.csAdd idle timeout + transport-specific zero-length read classification +91/-11

Add idle timeout + transport-specific zero-length read classification

• Introduces DefaultIdleTimeout (20s) and optional constructor args to configure transport semantics: whether a zero-length read means a closed connection and how long to wait for inactivity. Implements per-read idle cancellation to prevent NetworkStream.ReadAsync from hanging indefinitely, and uses the provided transport flag instead of relying on NetworkStream.CanRead for FIN detection.

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

SdCardTransferStalledException.csImprove stall timeout semantics and messages for idle-window stalls +18/-5

Improve stall timeout semantics and messages for idle-window stalls

• Clarifies Timeout meaning (overall deadline vs inactivity window) and adds a dedicated message variant when the transfer is abandoned due to inactivity with a known window, guiding the caller to retry with accurate context.

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

Tests (2) +346 / -0
SdCardFileReceiverTests.csAdd receiver-level TCP/WiFi stall + EOF-split coverage +198/-0

Add receiver-level TCP/WiFi stall + EOF-split coverage

• Adds a new test region focused on TCP/WiFi semantics: 1024-byte chunking, EOF marker split across chunk boundaries, throttled delivery gaps, idle-timeout stalls, and zero-length read classification differences between TCP and serial. Introduces a ThrottledStream test helper to simulate gapped socket-like delivery without returning empty reads.

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

SdCardOperationsTests.csAdd end-to-end WiFi download and StopLogging transport regression tests +148/-0

Add end-to-end WiFi download and StopLogging transport regression tests

• Adds SD download integration-style tests simulating firmware TCP chunk clamping and gaps, plus a silent device case validating idle-timeout behavior. Adds a regression test ensuring StopSdCardLoggingAsync over WiFi does not send LAN re-enable commands that would drop the connection.

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

Documentation (1) +2 / -2
ISdCardOperations.csUpdate SD download XML docs to include WiFi/TCP support +2/-2

Update SD download XML docs to include WiFi/TCP support

• Adjusts interface XML documentation to state SD downloads (to stream and to temp file) work over USB or WiFi/TCP.

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

@qodo-code-review

qodo-code-review Bot commented Aug 1, 2026

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. Binary-breaking ctor change ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
SdCardFileReceiver’s public constructor was modified to add parameters instead of adding an
overload, removing the old CLR signature that already-compiled consumers call. This can cause
MissingMethodException at runtime for downstream apps that upgrade the library without recompiling.
Code

src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[R68-72]

+    public SdCardFileReceiver(
+        Stream sourceStream,
+        int bufferSize = 16384,
+        bool zeroLengthReadMeansClosed = false,
+        TimeSpan? idleTimeout = null)
Relevance

●●● Strong

Team has accepted ABI-compat fixes; likely add overload/keep old ctor signature to avoid runtime
breaks.

PR-#321
PR-#349

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The receiver is a public class and its constructor signature now includes the additional parameters,
meaning the previous (Stream,int) constructor no longer exists for already-compiled callers.

src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[14-95]

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

## Issue description
`SdCardFileReceiver` is a public type and the PR changes its existing public constructor from `(Stream, int)` to `(Stream, int, bool, TimeSpan?)`. Optional parameters are compile-time only; already-compiled consumers still reference the old CLR signature and will fail at runtime.

## Issue Context
The PR description says “No public API was removed or changed in shape”, but changing the constructor signature *is* a binary-breaking public API change.

## Fix Focus Areas
- Reintroduce the original constructor signature and keep the new functionality via overloads.
- Avoid source ambiguity between overloads (don’t create two ctors both callable as `new SdCardFileReceiver(stream)` via optional params).
- Preserve current PR call sites that use named args (`zeroLengthReadMeansClosed`, `idleTimeout`).

### Suggested shape (one valid option)
- Keep/restore: `public SdCardFileReceiver(Stream sourceStream, int bufferSize = 16384)`
- Add: `public SdCardFileReceiver(Stream sourceStream, bool zeroLengthReadMeansClosed, TimeSpan? idleTimeout = null, int bufferSize = 16384)`
- Have both forward to a single private/internal implementation.

## Fix Focus Areas (code locations)
- src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[45-95]

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



Remediation recommended

2. Legacy ctor enables idle timeout ✓ Resolved 🐞 Bug ☼ Reliability ⭐ New
Description
The legacy SdCardFileReceiver(Stream,int) constructor delegates with idleTimeout:null, which
activates DefaultIdleTimeout (20s) even though this ctor’s XML docs describe the USB/serial path as
relying on per-read serial timeouts. This is a silent behavioral change for existing consumers of
the old ctor: any transport stream that can go quiet without returning 0 bytes (and that honors
cancellation tokens) may now be aborted by the idle window rather than only by the caller’s overall
deadline.
Code

src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[R66-68]

+    public SdCardFileReceiver(Stream sourceStream, int bufferSize = DefaultBufferSize)
+        : this(sourceStream, zeroLengthReadMeansClosed: false, idleTimeout: null, bufferSize: bufferSize)
+    {
Relevance

●●● Strong

Team has accepted fixes to prevent silent behavior changes/backward-compat issues; legacy ctor
should preserve prior semantics/docs.

PR-#406
PR-#321

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The legacy ctor passes idleTimeout: null, and the overload translates null into
DefaultIdleTimeout (20s) and stores it into _idleTimeout, which causes ReceiveAsync to create
an idle CTS and cancel reads after that duration.

src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[22-38]
src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[66-69]
src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[108-121]
src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[206-214]

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

### Issue description
`SdCardFileReceiver(Stream, int)` is documented as the USB/serial constructor, but it currently delegates with `idleTimeout: null`, which the overload converts into `DefaultIdleTimeout` (20s). This makes the legacy ctor apply a network-oriented idle window by default, changing behavior for existing callers.

### Issue Context
- The overload interprets `idleTimeout == null` as `DefaultIdleTimeout`, and `ReceiveAsync` enforces it by canceling the read after the window.
- For the legacy serial ctor, the most consistent behavior is to **disable** the idle window by default and rely on the serial per-read timeout / zero-length read semantics.

### Fix Focus Areas
- src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[66-68]

### Suggested change
In the legacy constructor, pass `Timeout.InfiniteTimeSpan` instead of `null`:
```csharp
public SdCardFileReceiver(Stream sourceStream, int bufferSize = DefaultBufferSize)
   : this(sourceStream,
          zeroLengthReadMeansClosed: false,
          idleTimeout: Timeout.InfiniteTimeSpan,
          bufferSize: bufferSize)
{
}
```
This keeps the legacy ctor aligned with its USB/serial semantics while still letting TCP/WiFi callers use the new overload (and its default idle timeout) explicitly.

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


3. Per-read CTS allocation 🐞 Bug ➹ Performance
Description
ReceiveAsync allocates a new linked CancellationTokenSource per read when idle timeout is enabled,
which can become a high-allocation hot path on WiFi/TCP where downloads arrive as long runs of
1024-byte reads. This increases GC pressure and may become measurable for large files or repeated
downloads.
Code

src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[R179-187]

+                // One inactivity window per read. Allocated per iteration rather than reset in
+                // place because a CancellationTokenSource cannot be un-cancelled: reusing one
+                // would turn a byte that arrived at the exact instant the window closed into a
+                // permanently dead token for every read after it.
+                using var idleCts = _idleTimeout is null
+                    ? null
+                    : CancellationTokenSource.CreateLinkedTokenSource(token);
+                idleCts?.CancelAfter(_idleTimeout!.Value);
+
Relevance

●● Moderate

Perf micro-allocation concern is plausible, but no close precedent; they’ve accepted CTS patterns
for cancellation/timeout logic.

PR-#248
PR-#295

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code allocates a linked CTS inside the read loop, and the added tests explicitly document that
WiFi/TCP transfers arrive as a long run of 1024-byte reads regardless of the receiver buffer
size—multiplying the number of loop iterations and allocations.

src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[175-191]
src/Daqifi.Core.Tests/Device/SdCard/SdCardFileReceiverTests.cs[516-522]

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

## Issue description
`ReceiveAsync` creates and disposes a linked `CancellationTokenSource` every loop iteration to implement the idle timeout. On WiFi/TCP, the firmware delivers SD data in 1024-byte chunks, so large downloads can execute many iterations and allocate many CTS instances.

## Issue Context
This is not a correctness bug; it’s a potentially measurable allocation/GC overhead introduced by the new idle-timeout mechanism.

## Fix Focus Areas
- Consider reusing a single linked idle-timeout CTS across iterations and calling `CancelAfter(...)` each iteration to reset the window.
- If you reuse a CTS, handle the edge race where the idle CTS is canceled at the same instant a read completes with >0 bytes (recreate the CTS in that case before the next iteration).
- Alternatively, benchmark current behavior and only optimize if it shows up in profiles.

## Fix Focus Areas (code locations)
- src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[175-214]
- src/Daqifi.Core.Tests/Device/SdCard/SdCardFileReceiverTests.cs[516-572]

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


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Previous review results

Review updated until commit 4c7d8e6

Results up to commit d0e0f57 ⚖️ Balanced


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


Action required
1. Binary-breaking ctor change ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
SdCardFileReceiver’s public constructor was modified to add parameters instead of adding an
overload, removing the old CLR signature that already-compiled consumers call. This can cause
MissingMethodException at runtime for downstream apps that upgrade the library without recompiling.
Code

src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[R68-72]

+    public SdCardFileReceiver(
+        Stream sourceStream,
+        int bufferSize = 16384,
+        bool zeroLengthReadMeansClosed = false,
+        TimeSpan? idleTimeout = null)
Relevance

●●● Strong

Team has accepted ABI-compat fixes; likely add overload/keep old ctor signature to avoid runtime
breaks.

PR-#321
PR-#349

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The receiver is a public class and its constructor signature now includes the additional parameters,
meaning the previous (Stream,int) constructor no longer exists for already-compiled callers.

src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[14-95]

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

## Issue description
`SdCardFileReceiver` is a public type and the PR changes its existing public constructor from `(Stream, int)` to `(Stream, int, bool, TimeSpan?)`. Optional parameters are compile-time only; already-compiled consumers still reference the old CLR signature and will fail at runtime.

## Issue Context
The PR description says “No public API was removed or changed in shape”, but changing the constructor signature *is* a binary-breaking public API change.

## Fix Focus Areas
- Reintroduce the original constructor signature and keep the new functionality via overloads.
- Avoid source ambiguity between overloads (don’t create two ctors both callable as `new SdCardFileReceiver(stream)` via optional params).
- Preserve current PR call sites that use named args (`zeroLengthReadMeansClosed`, `idleTimeout`).

### Suggested shape (one valid option)
- Keep/restore: `public SdCardFileReceiver(Stream sourceStream, int bufferSize = 16384)`
- Add: `public SdCardFileReceiver(Stream sourceStream, bool zeroLengthReadMeansClosed, TimeSpan? idleTimeout = null, int bufferSize = 16384)`
- Have both forward to a single private/internal implementation.

## Fix Focus Areas (code locations)
- src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[45-95]

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



Remediation recommended
2. Per-read CTS allocation 🐞 Bug ➹ Performance
Description
ReceiveAsync allocates a new linked CancellationTokenSource per read when idle timeout is enabled,
which can become a high-allocation hot path on WiFi/TCP where downloads arrive as long runs of
1024-byte reads. This increases GC pressure and may become measurable for large files or repeated
downloads.
Code

src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[R179-187]

+                // One inactivity window per read. Allocated per iteration rather than reset in
+                // place because a CancellationTokenSource cannot be un-cancelled: reusing one
+                // would turn a byte that arrived at the exact instant the window closed into a
+                // permanently dead token for every read after it.
+                using var idleCts = _idleTimeout is null
+                    ? null
+                    : CancellationTokenSource.CreateLinkedTokenSource(token);
+                idleCts?.CancelAfter(_idleTimeout!.Value);
+
Relevance

●● Moderate

Perf micro-allocation concern is plausible, but no close precedent; they’ve accepted CTS patterns
for cancellation/timeout logic.

PR-#248
PR-#295

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code allocates a linked CTS inside the read loop, and the added tests explicitly document that
WiFi/TCP transfers arrive as a long run of 1024-byte reads regardless of the receiver buffer
size—multiplying the number of loop iterations and allocations.

src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[175-191]
src/Daqifi.Core.Tests/Device/SdCard/SdCardFileReceiverTests.cs[516-522]

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

## Issue description
`ReceiveAsync` creates and disposes a linked `CancellationTokenSource` every loop iteration to implement the idle timeout. On WiFi/TCP, the firmware delivers SD data in 1024-byte chunks, so large downloads can execute many iterations and allocate many CTS instances.

## Issue Context
This is not a correctness bug; it’s a potentially measurable allocation/GC overhead introduced by the new idle-timeout mechanism.

## Fix Focus Areas
- Consider reusing a single linked idle-timeout CTS across iterations and calling `CancelAfter(...)` each iteration to reset the window.
- If you reuse a CTS, handle the edge race where the idle CTS is canceled at the same instant a read completes with >0 bytes (recreate the CTS in that case before the next iteration).
- Alternatively, benchmark current behavior and only optimize if it shows up in profiles.

## Fix Focus Areas (code locations)
- src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs[175-214]
- src/Daqifi.Core.Tests/Device/SdCard/SdCardFileReceiverTests.cs[516-572]

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


Qodo Logo

Comment thread src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs Outdated
Comment thread src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs
…nd 1)

Adding optional parameters to the existing public (Stream, int) constructor
changed its CLR signature, so a downstream app that upgrades the Daqifi.Core
package without recompiling would hit MissingMethodException. The original
constructor is restored verbatim and delegates to a new overload that carries
the transport semantics. A reflection test pins the old signature; verified by
mutation that it fails when the constructor is widened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Round 1 addressed.

1 — binary-breaking constructor: fixed (cf96926). Correct catch. The original SdCardFileReceiver(Stream, int) is restored verbatim and delegates to a new (Stream, bool, TimeSpan?, int) overload; the required bool sits second so the two can never be ambiguous. Added a reflection test pinning the old CLR signature, mutation-verified to fail when the constructor is widened. Also corrected the inaccurate "No public API was removed or changed in shape" line in the PR description.

2 — per-read CTS allocation: keeping it, with reasoning on the thread. Reusing one CTS needs recreate-on-race handling because a cancelled CTS cannot be un-cancelled — a byte arriving exactly as the window closes would leave every later read failing instantly. Per-iteration allocation makes that unrepresentable, and it sits next to a socket read and a stream write that dominate it. The bench file was 2,551 bytes, i.e. three iterations.

Full suite green: 2,355 tests on net9.0 and net10.0.

/agentic_review

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment thread src/Daqifi.Core/Device/SdCard/SdCardFileReceiver.cs
@qodo-code-review

Copy link
Copy Markdown

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

…ture (Qodo round 2)

Restoring the (Stream, int) constructor bought binary compatibility, but it
delegated with idleTimeout: null, which resolves to the new 20-second default.
That switched on an inactivity window for exactly the pre-compiled consumers
the overload exists to protect: any transport that goes quiet without returning
zero bytes, and honors cancellation, would start being abandoned mid-transfer
where it used to run to the caller's own deadline. A silent behavior change is
worse than the loud binary break the overload prevents.

The legacy ctor now passes Timeout.InfiniteTimeSpan, restoring pre-PR semantics
exactly. The inactivity window is opt-in on the new overload, which is where
DaqifiStreamingDevice asks for it on both transports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Round 2 addressed.

1 — legacy ctor enabled the idle timeout: fixed (4c7d8e6). Correct and worth naming precisely: restoring the CLR signature bought binary compatibility, then delegating with idleTimeout: null quietly took away behavioral compatibility. An overload that exists solely for pre-compiled callers owes both, and the silent version is arguably worse than the loud MissingMethodException it was added to prevent. The legacy ctor now passes Timeout.InfiniteTimeSpan, restoring pre-PR semantics exactly; the window is opt-in on the new overload.

This does not weaken the PR: DownloadSdCardFileAsync uses the new overload and passes idleTimeout explicitly, so the device keeps the window on both transports — which the bench showed USB genuinely needs, since SerialStream.ReadAsync on macOS does not return 0 bytes on the port's read timeout.

Two tests, and I'll be straight about what each buys: the behavioral one pins that the caller's deadline is still what ends a quiet transfer; the state one pins that the window is off at all. Mutation-verified by reverting the delegation — only the state test failed, because the behavioral test can't rule out a window longer than its own 400 ms deadline and the default is 20 s. So the state assertion is the one doing the catching.

2 — per-read CTS allocation: unchanged, restated on the thread. Reusing a CTS needs recreate-on-race handling since a cancelled CTS can't be un-cancelled; per-iteration allocation makes that unrepresentable. This PR's own review summary agrees, calling it "a reasonable tradeoff for correctness and clarity." Recording it as a documented disagreement rather than churning working code.

Suite green: 2,357 tests on net9.0 and net10.0. No bench work this round — hardware is unattended.

/agentic_review

@tylerkron
tylerkron merged commit 2521fac into main Aug 2, 2026
1 check passed
@tylerkron
tylerkron deleted the fix/327-sd-over-tcp branch August 2, 2026 01:49
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.

1 participant