fix(device): cap the backlog of sends parked during a long exclusive operation - #505
Conversation
…operation Send() parks a message whenever another flow owns the device, and the queue it parks into had no limit. Exclusive operations can run for a very long time — an SD card download is allowed thirty minutes, a firmware update longer — so a UI or agent sending at even 10 Hz throughout one parked ~18,000 closures, and then had every one of those stale commands replayed at the device the moment the operation ended. The backlog is now capped at DefaultMaxDeferredSends (1024) and overflows drop-oldest, which is the right way round for the level-setting commands that actually get parked: a superseded "set this pin" is worth less than the memory it costs. Discards are counted by the new DroppedDeferredSendCount, and the first overflow of each backlog logs one warning. closes #492 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoCap deferred Send() backlog during long exclusive operations
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Code Review by Qodo
1.
|
Qodo round 1: the new tests coordinated their phases with a default TaskCompletionSource, so entered.SetResult() inside the exclusive operation could resume the test inline on the operation's own thread. The repo already standardizes on RunContinuationsAsynchronously for cross-thread signalling (DaqifiDeviceAsyncLifecycleTests, SerialDeviceFinderTests); these now match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 1 triage — taken, with one note on the reasoning. "TCS continuation reentrancy" — valid, fixed in Worth being precise about what the hazard is and isn't, since it changes nothing about the production fix. An inline resumption here could not have made the test's sends bypass deferral: Note the shape came from the existing deferral tests directly above it in the same file, which still use the default TCS; those are left alone as out of scope for this PR. Full suite re-run green after the change: net9.0 (2908 Core + 43 Mcp) and net10.0 (2908), 0 failures, 0 warnings. Test-only commit, so the bench result on |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 3e6d4d3 |
|
Qodo-clean, CI green — ready for review. Round 2 on head CI
Not merging — for review. |
What was wrong
Send()never blocks — when another flow owns the device it parks the message and returns, and the message goes out in order once that flow finishes. The queue it parked into had no limit.That is fine for a text query, which owns the device for milliseconds. It is not fine for the operations that own it for a long time: an SD card download is allowed thirty minutes, and a firmware update longer. A UI or agent sending DIO/PWM/status commands at even 10 Hz throughout a download parked about 18,000 closures — a couple of megabytes, plus every message object they held alive — and then, the moment the download ended, replayed all of them at the device in one burst. So the memory growth came with a second surprise: thousands of commands the caller had long since superseded, all arriving at once at a device that had moved on.
How it was fixed
The backlog is capped at
DaqifiDevice.DefaultMaxDeferredSends(1024) and overflows by discarding its oldest entries. Drop-oldest is the right way round for what actually gets parked — these are level-setting commands ("set this pin", "set that duty cycle"), where the newest instruction is the one the caller currently wants and the superseded one is worth less than the memory it costs. Discards are counted by a newDroppedDeferredSendCountproperty, mirroring the existingDroppedLiveSampleCount, and the first overflow of each backlog logs one warning so this is not something you have to already know about to notice.Things you may want to push back on:
DeviceConnectionOptionsknob for it.internal virtual MaxDeferredSendsexists purely as a test seam.SendFailed. Raising a public event from inside the deferral gate would put consumer code under a lock thatSend()promises not to block on, and moving it outside makes ordering murky; the counter is the pattern the library already uses for the identical drop-oldest decision on live samples.DefaultMaxDeferredSendsandDroppedDeferredSendCountare new public members. Additive only — no interface changed, no existing signature touched.Verification
DaqifiDeviceOperationSerializationTests: drop-oldest keeps the newest N in order; a backlog under the cap drops nothing (the guard on the existing ordering guarantees, which are unchanged); 2000 sends hammered through a held operation leave the backlog bounded, sampled as it grows rather than only at the end; the shipped default cap really is the one enforced; an idle device drops nothing even with a cap of one; and the counter accumulates across operations while the warning stays at one per overflowing backlog. Four of the six were re-run against the un-fixed code first — all four failed, the two guards passed — so they pin the bug rather than the fix./dev/cu.usbmodem1101: four connect → status → 3 s @ 500 Hz stream → disconnect cycles, exit 0, 1188–1189 samples (this unit's known ~79% clock ratio),snand firmware unchanged, cleanDisconnected. Non-destructive; serial only.closes #492
Not merging — for review.