Skip to content

feat(device): serialize operations per device so concurrent callers stop corrupting each other's replies (closes #342) - #421

Merged
tylerkron merged 5 commits into
mainfrom
feature/342-per-device-operation-serialization
Aug 2, 2026
Merged

feat(device): serialize operations per device so concurrent callers stop corrupting each other's replies (closes #342)#421
tylerkron merged 5 commits into
mainfrom
feature/342-per-device-operation-serialization

Conversation

@tylerkron

@tylerkron tylerkron commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Closes #342.

Why

Core only ever serialized one thing: text queries (SD listings, diagnostics, capability reads). Everything else — EnableChannel, SetDioValue, StartStreaming, network config — went straight out with no coordination at all.

That combination has a sharp edge. While a text query is running, Core stops the normal reader and puts a temporary text reader on the stream to collect the reply. If another thread sends a command in that window, the device's answer to that command lands in the query's reply. The caller gets someone else's data back and has no way to tell.

It is not theoretical. On the bench, a status poll every 200 ms from two background threads was enough to corrupt every single query reply (below). And because the contract was never written down, every concurrent consumer had to discover this and build its own lock — our own MCP server included.

What

Core now owns this, and the contract is written down in docs/DEVICE_INTERFACES.md:

  1. Any single call is safe from any thread. Nothing you do with individual calls can produce corrupt SCPI, and a query's reply always belongs to that query.
  2. A sequence that must not be split goes in device.RunExclusiveAsync(...). That is the one thing a single call cannot express — "set the direction, then drive the pin" needs to happen with nothing in between.
  3. Connect/Disconnect/Dispose serialize themselves and tear down on a bounded wait, so a stuck operation can never block shutdown forever.

The MCP server's hand-rolled semaphore shrank to just the connection registry. A nice side effect: it used to be one process-wide lock, so two devices could never be operated at once. Now they can.

How

The interesting decisions:

One lock, not two. RunExclusiveAsync takes the same per-device semaphore text queries already use. A second, outer lock would have needed an ordering, and the code that would have to respect that ordering — disconnect, dispose, the reconnect loop, every SD operation — is exactly the code that must never deadlock. Reusing the existing lock means there is no ordering to get wrong. It is made reentrant per logical flow (the AsyncLocal technique already in this file), so an exclusive block can freely call the SD and diagnostic methods that open a text query of their own, and can call Disconnect.

Send() defers instead of blocking. Send() is documented fire-and-forget, and making it wait on a lock would have been a silent contract change and a great way to stall a UI thread. So while another thread owns the device, Send() parks the message and returns immediately; the owner flushes it, in order, on the way out. The caller sees the same non-blocking call it always did — the message just reaches the device slightly later, which beats having it corrupt somebody's query. Called out in the docs.

The reader is untouched. Streaming callbacks, the read loop and frame decode never take this lock, so a live stream keeps flowing while control operations run. Verified on hardware.

Also: the text query now lets the outbound queue drain before it takes the stream (so replies to earlier commands go to the normal reader where they belong), and the producer-less direct-write path got a lock — that was the one place SCPI bytes could genuinely interleave mid-command.

RunExclusiveAsync is on DaqifiDevice rather than IDevice, deliberately: adding it to the interface would break every external implementer for an API that only the concrete device can honour.

Bench evidence

Nyquist 1, FW 3.7.2, over both transports. Same harness built twice — once against origin/main, once against this branch — so it is a true before/after. Realistic load: two background threads doing a status poll plus DIO writes every 200 ms, while a foreground loop runs 15 text queries.

The probe is SYSTem:ERRor:COUNt?, whose clean reply is a single bare integer. Anything else in the reply is somebody else's data. The harness self-calibrates first by running the probe with no concurrent traffic (5/5 clean on both builds), so a firmware quirk can't be mistaken for the bug.

USB (/dev/cu.usbmodem1101)

origin/main this branch
Queries with a corrupted reply 15 of 15 0 of 15
Stray lines mixed into replies 765 0
Indivisible sequences (48 across 4 threads) n/a — no API 48/48, 0 overlaps, no deadlock
Stream continuity under control traffic 1506 samples, 0 silent seconds 1506 samples, 0 silent seconds
Control ops during streaming 1293, 0 errors 1290, 0 errors
Device SCPI error queue afterwards 0 0

A corrupted reply on main looks like this — the real answer (0) followed by 50+ lines belonging to another thread:

[0 | 0,"No error" | 0,"No error" | 0,"No error" | ... ]

The branch run was repeated end-to-end and passed identically both times. Phase A also got ~4x faster (81.6s → 18.3s), because on main every polluted query kept collecting until its inactivity timeout.

Under a deliberately pathological load (~200 fire-and-forget queries/sec, far past anything real), the branch cut stray lines from 23,410 to 3,123 rather than to zero: replies to the deferred burst can still land in the next query. That is inherent to sending queries fire-and-forget — Send() gives you nothing to match a reply against — and the fix for it is to use the …Async query methods, which serialize properly. Recording it here rather than hiding it.

WiFi (192.168.1.30, TCP 9760) — run after a power cycle, branch first.

origin/main this branch
Queries answered at all 13 of 15 (2 threw) 15 of 15
Replies that came back empty 11 0
Queries with a corrupted reply 2 1
Stray lines mixed into replies 36 1
Indivisible sequences n/a — no API 48/48, 0 overlaps, no deadlock
Stream continuity under control traffic never reached — device dropped 742 samples, 0 silent seconds, 1287 control ops, 0 errors
Device SCPI error queue afterwards 0

The headline on main over WiFi is not the pollution, it is the 11 empty replies: the query's own answer never arrived at all. TCP delivers the interleaved traffic in different-sized chunks than USB CDC, so a command written into the exchange window doesn't just add a line — it can consume the reply the caller was waiting for. On this branch every query got its answer.

Nothing deadlocked at the higher, more variable latency (RTT ranged 29–215 ms), and a live stream kept flowing throughout while three threads drove DIO — the specific thing the operation lock must never block.

The one imperfect number is the single stray line on the branch, and it is the fire-and-forget-query residual described above, not a serialization failure. The giveaway is the ordering: ["0,\"No error\"", "0"] — the stray line arrives before the real answer, i.e. it is a late reply to an earlier deferred command, which WiFi's ~100 ms round trip makes far likelier to land in the next query's window than USB's microseconds. On main the pattern is the opposite: the real answer followed by foreign lines written into the window.

One caveat, stated plainly. Both origin/main WiFi runs (this one and an earlier attempt) ended with the unit dropping off the network during the streaming phase; both branch runs completed all phases. I am not claiming the branch is more stable — this unit's WiFi stack is known to wedge under cumulative connect churn, the main run was the second connect of each session, and two samples is not evidence. Noting it only so the pattern isn't mistaken for a clean sweep. After the drop the unit still passed a full run over USB, so it was the transport, not the device.

Tests

14 new tests in DaqifiDeviceOperationSerializationTests, covering mutual exclusion, lock release on a throwing body, reentrancy, a nested text query inside an exclusive block, Disconnect from inside one, teardown while an operation is in flight, deferral (held back, non-blocking, in-order, owner not deferred), and that the inbound path is never blocked.

Full suite green: 2352 Core tests × net9.0/net10.0, 23 MCP tests.

Worth calling out — reviewing my own diff turned up a bug I had introduced: the new queue-drain sat inside the reader-swap's try/finally, so a cancelled query would "restart" a reader that was never stopped and subscribe the inbound handler twice, dispatching every frame twice for the rest of the session. Fixed, with a regression test that I confirmed fails without the fix.

🤖 Generated with Claude Code

…re indivisible sequences (closes #342)

Core serialized text exchanges but nothing else, so a Send() from another
thread could be written onto the wire while a text query owned the stream
and have its reply collected as part of that query's answer. Every
concurrent consumer had to build its own gate to avoid it.

Core now owns it:

- RunExclusiveAsync wraps a sequence of commands so nothing splits it.
  Reentrant on the same flow, so a body can call the SD/diagnostic methods
  that open a text exchange of their own, and can Disconnect.
- Send() defers rather than blocks while another flow owns the device. It
  still returns immediately; the message goes out, in order, afterwards.
- One lock, not two. RunExclusiveAsync and the text exchange share the
  existing per-device semaphore, so there is no ordering to get wrong.
- The text exchange lets the outbound queue drain before it takes the
  stream, so replies to earlier commands go to the protobuf consumer.
- The producer-less direct-write path is serialized; it was the one place
  SCPI bytes could genuinely interleave mid-command.

The MCP server's hand-rolled gate shrinks to the connection registry, so
two devices now run genuinely in parallel instead of behind one
process-wide semaphore.

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:45
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Serialize operations per device with RunExclusiveAsync and deferred Send()

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

Grey Divider

AI Description

• Add RunExclusiveAsync to serialize multi-command sequences with a per-device lock.
• Defer Send() during exclusive ops/text queries and flush in order afterward.
• Document the thread-safety contract; update MCP agent and add serialization tests.
Diagram

graph TD
A["Concurrent callers"] --> B["DaqifiDevice"] --> C["Per-device op lock"]
C --> D["RunExclusiveAsync"] --> F["Deferred Send queue"] --> G["Producer/direct write"] --> H["Transport stream"]
C --> E["Text exchange"] --> F --> G --> H
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Block Send() behind the operation lock
  • ➕ Simpler mental model: Send() cannot overtake/lag other operations
  • ➕ No need to buffer deferred sends
  • ➖ Silent contract change: Send() stops being fire-and-forget and can stall UI threads
  • ➖ Higher deadlock risk if Send() is called in contexts that already coordinate with lifecycle/text exchange
2. Introduce a separate operation semaphore (outer lock)
  • ➕ Separates concerns: one lock for text exchange internals, one for user exclusivity
  • ➖ Requires a strict lock ordering across disconnect/dispose/reconnect/SD ops
  • ➖ Increases deadlock surface exactly in teardown and recovery paths
3. Protocol-level request/response correlation (IDs)
  • ➕ Avoids stream ownership swapping hazards by matching replies to requests
  • ➕ Can allow higher concurrency without serialization
  • ➖ Requires firmware/protocol changes and likely a major compatibility migration
  • ➖ Much larger scope than needed to fix reply corruption today

Recommendation: Keep the PR’s approach: reuse the existing per-device semaphore as the single operation lock, add reentrancy via AsyncLocal, and defer (not block) Send() from other flows. This minimizes deadlock risk (no lock ordering), preserves Send()’s fire-and-forget contract, and fixes the concrete reply-corruption hazard around text exchanges. The added drain-before-swap step and the targeted tests are appropriate safeguards for the new behavior.

Files changed (4) +1222 / -147

Refactor (1) +84 / -106
DaqifiAgent.csMove MCP tool serialization to per-device RunExclusiveAsync +84/-106

Move MCP tool serialization to per-device RunExclusiveAsync

• Removes the process-wide semaphore from device-level tool operations (channel configuration, DIO, PWM, sample rate, SD logging) and wraps multi-command sequences in device.RunExclusiveAsync instead. Keeps the semaphore only for connection-registry mutation (connect/disconnect/shutdown), enabling true parallelism across different devices while preserving per-device safety.

src/Daqifi.Mcp/DaqifiAgent.cs

Tests (1) +649 / -0
DaqifiDeviceOperationSerializationTests.csAdd concurrency tests for exclusive ops, Send deferral, and teardown +649/-0

Add concurrency tests for exclusive ops, Send deferral, and teardown

• Introduces a comprehensive test suite validating per-device mutual exclusion, AsyncLocal-based reentrancy, Send() deferral semantics (non-blocking, ordered flush), and bounded disconnect behavior. Includes transports/streams to deterministically observe writes and simulate blocked outbound draining during text exchanges.

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

Documentation (1) +78 / -5
DEVICE_INTERFACES.mdDefine and exemplify the device thread-safety contract +78/-5

Define and exemplify the device thread-safety contract

• Replaces the previous narrow note about Send() queue thread safety with a three-part contract: single-call safety, RunExclusiveAsync for indivisible sequences, and bounded lifecycle serialization. Adds concrete examples and clarifies what remains out of scope (streaming callbacks, mutable IChannel views, duplicate device instances).

docs/DEVICE_INTERFACES.md

Other (1) +411 / -36
DaqifiDevice.csSerialize per-device operations; add RunExclusiveAsync and defer Send() +411/-36

Serialize per-device operations; add RunExclusiveAsync and defer Send()

• Promotes the existing text-exchange semaphore into the single per-device operation lock, then adds RunExclusiveAsync (generic + non-generic) with AsyncLocal reentrancy. Implements Send() deferral for non-owning flows, ordered flushing on release, a direct-write mutex for producer-less writes, and a short outbound-queue drain before swapping to the text consumer to prevent reply misattribution; teardown paths treat re-entry as nested to avoid self-waits.

src/Daqifi.Core/Device/DaqifiDevice.cs

@tylerkron

Copy link
Copy Markdown
Contributor Author

WiFi leg: attempted again, still blocked by the bench unit's WiFi stack.

The unit had recovered on its own (3/3 ping, 0% loss, TCP 9760 open), so I retook the bench and went straight at the branch build over WiFi — the run that actually matters.

It never got a measurement. The device left the network during the very first TCP connect, surfacing as an 8-second channel-configuration timeout, then 100% packet loss. No second attempt was made, per the don't-hammer-it rule.

Immediately afterwards, over USB, the same branch build passed every phase again — 15/15 clean replies, 48/48 indivisible sequences with 0 overlaps, 1506 samples with no silent second, empty SCPI error queue. That is the third consecutive identical USB pass, so the unit is healthy and this is the transport wedging under cumulative connect churn, not a fault in the change.

The PR body now records both WiFi attempts and what each did and did not show. Net position:

  • USB: decisive. 15/15 replies corrupted on origin/main, 0/15 on this branch, reproduced three times.
  • WiFi: outstanding. Partial origin/main evidence only (11 of 15 replies empty before it dropped); no branch measurement.

Re-running the WiFi leg after a physical power cycle is the one piece of verification still missing. The mechanism is not transport-specific — the lock sits above the transport and the code path is identical for serial and TCP — but I would rather flag that than let USB stand in for it silently.

No code changed since the last review.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

qodo-code-review Bot commented Aug 1, 2026

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. Stale owner bypasses deferral ✓ Resolved 🐞 Bug ≡ Correctness ⭐ New
Description
FinishDisconnect() always calls ResetDeferralState(), clearing _operationInFlight/_deferredSends
even when teardown could not acquire _textExchangeLock and a prior RunExclusiveAsync flow may still
own it. After reconnect, Send() from other flows will no longer defer and can run concurrently with
that stale owner flow’s Send() calls (which bypass deferral via _ownsOperationLock), breaking
operation/session isolation and risking reply ownership corruption.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1242-1248]

+        private void ResetDeferralState()
+        {
+            lock (_deferralGate)
+            {
+                _deferredSends = null;
+                _operationInFlight = false;
+            }
Relevance

●● Moderate

Concurrency edge case; likely important but no close precedent specifically about stale owner bypass
after reconnect.

PR-#418

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
ResetDeferralState clears the deferral signals even though a previous flow may still hold the
semaphore; TryDeferSend uses only those signals to decide whether to defer and explicitly bypasses
deferral for a flow with _ownsOperationLock. The new test demonstrates the supported scenario
(disconnect without taking the lock, then reconnect while the old operation is still in flight),
which is where stale-owner overlap becomes possible.

src/Daqifi.Core/Device/DaqifiDevice.cs[1218-1248]
src/Daqifi.Core/Device/DaqifiDevice.cs[1281-1297]
src/Daqifi.Core/Device/DaqifiDevice.cs[1915-1926]
src/Daqifi.Core.Tests/Device/DaqifiDeviceOperationSerializationTests.cs[404-451]

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

## Issue description
`FinishDisconnect()` unconditionally calls `ResetDeferralState()`, which clears `_operationInFlight` and drops `_deferredSends` even when teardown *did not* acquire the operation semaphore (`_textExchangeLock`). In the bounded-teardown/abandon path, an in-flight `RunExclusiveAsync` may still hold the semaphore and still have `_ownsOperationLock` set in its logical flow.

After a reconnect, new callers will see `_operationInFlight == false && _deferredSends == null` and therefore will not defer (`TryDeferSend` returns false), while the stale owner flow also won’t defer (because `_ownsOperationLock.Value` is still true). This permits overlapping “exclusive” flows across sessions.

## Issue Context
This is the exact reconnect sequence exercised by the new test `Send_AfterATeardownThatCouldNotTakeTheLock_IsStillDelivered()` (disconnect proceeds while an exclusive operation remains in flight, then reconnect happens).

## Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[1218-1248]
- src/Daqifi.Core/Device/DaqifiDevice.cs[1922-1925]

## Suggested fix approach
Implement *session generation* so a flow that owned the operation lock in a previous session cannot retain “owner” privileges after teardown:

1. Add an integer `_sessionGeneration` field that increments on session teardown (in `FinishDisconnect`, before/around `ResetDeferralState`).
2. Replace `_ownsOperationLock` (or augment it) with an `AsyncLocal<int?> _operationOwnerGeneration` (or `(bool owns, int gen)`), set when acquiring `_textExchangeLock`.
3. Update `TryDeferSend` to treat the caller as “owning” only if its stored generation matches the current `_sessionGeneration`. If it doesn’t match, it must behave as a non-owner (so it can’t bypass deferral after reconnect).
4. Ensure `RunExclusiveAsync`/text-exchange lock acquisition sets the generation for that flow, and `finally` clears it only if the generation still matches.

Optional (stronger) hardening for the abandoned-lock path:
- If teardown proceeds without acquiring `_textExchangeLock`, consider disposing/replacing the semaphore for the next session so future `RunExclusiveAsync`/text exchanges don’t deadlock behind an abandoned holder; existing code already tolerates `ObjectDisposedException` on release.

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


2. Deferral state not reset ✓ Resolved 🐞 Bug ≡ Correctness
Description
FinishDisconnect() drops the deferred-send backlog but does not clear _operationInFlight, so after a
disconnect that proceeds without acquiring the operation/text-exchange lock, Send() can keep
deferring after a reconnect with no guaranteed drainer (messages are accepted but may never be
delivered). This is especially problematic in the exact scenario Disconnect is designed for: an
in-flight operation that doesn’t complete within the bounded teardown wait.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1221-1226]

+        private void DiscardDeferredSends()
+        {
+            lock (_deferralGate)
+            {
+                _deferredSends = null;
+            }
Relevance

●●● Strong

Team often accepts disconnect/teardown state-reset fixes to prevent stale state across reconnects.

PR-#418
PR-#356

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The teardown path explicitly allows disconnect to proceed without acquiring the operation lock; in
that case an in-flight operation may never run its normal flush/cleanup. With _operationInFlight
left true and _deferredSends nulled, TryDeferSend() will continue to defer by creating a new
queue, but only operation exit paths call FlushDeferredSends() to clear the state and replay, so
there is no guaranteed drainer post-teardown/reconnect.

src/Daqifi.Core/Device/DaqifiDevice.cs[1783-1812]
src/Daqifi.Core/Device/DaqifiDevice.cs[1893-1904]
src/Daqifi.Core/Device/DaqifiDevice.cs[1217-1227]
src/Daqifi.Core/Device/DaqifiDevice.cs[1266-1275]
src/Daqifi.Core/Device/DaqifiDevice.cs[1138-1147]

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

### Issue description
`FinishDisconnect()` now calls `DiscardDeferredSends()` to prevent a deferred backlog from leaking across sessions, but `DiscardDeferredSends()` only clears `_deferredSends` and leaves `_operationInFlight` unchanged. Because `TryDeferSend()` defers when `_operationInFlight` is true even if `_deferredSends` is null, a disconnect that tears down without acquiring the operation lock can leave the device stuck in “deferring” mode after a reconnect, with no guaranteed code path to flush.

### Issue Context
Teardown is explicitly best-effort around `_textExchangeLock` (bounded wait / cancellation), so this state can persist exactly when an operation is wedged and never reaches its `finally` to call `FlushDeferredSends()`.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[1217-1227]
- src/Daqifi.Core/Device/DaqifiDevice.cs[1893-1904]
- src/Daqifi.Core/Device/DaqifiDevice.cs[1266-1274]

### What to change
- In `DiscardDeferredSends()` (or directly in `FinishDisconnect` under the same `_deferralGate`), clear **both** `_deferredSends` and `_operationInFlight` together so teardown fully resets the deferral state.
- Keep the update atomic under `_deferralGate` (same rationale already documented for `_operationInFlight` + `_deferredSends` moving together).

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


3. Flush can block Send ✓ Resolved 🐞 Bug ☼ Reliability
Description
FlushDeferredSends() replays deferred sends while holding _deferralGate after MaxFlushRounds,
so concurrent Send() calls can block on that lock for the duration of potentially blocking I/O
(e.g., direct stream.Write). This violates the documented “Send() never blocks” contract and can
stall caller threads under sustained contention.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1138-1147]

+            lock (_deferralGate)
+            {
+                _operationInFlight = false;
+
+                var remaining = _deferredSends;
+                _deferredSends = null;
+                if (remaining != null)
+                {
+                    ReplayDeferredSends(remaining);
+                }
Relevance

●●● Strong

Repository has strong precedent for fixing lock/teardown/concurrency issues to uphold documented
contracts and avoid stalls.

PR-#196
PR-#418

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new fallback path holds _deferralGate while replaying deferred sends; Send() attempts to
take _deferralGate during deferral checks, so it can block until replay finishes. The replay
delegates call SendNow(...), which can block on synchronous stream writes for non-string payloads.

src/Daqifi.Core/Device/DaqifiDevice.cs[1115-1148]
src/Daqifi.Core/Device/DaqifiDevice.cs[1189-1206]
src/Daqifi.Core/Device/DaqifiDevice.cs[1866-1920]

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

### Issue description
`FlushDeferredSends()` has a fallback path (after `MaxFlushRounds`) that holds `_deferralGate` while calling `ReplayDeferredSends(...)`. Since `Send()` needs `_deferralGate` (via `TryDeferSend`) and deferred sends can perform blocking I/O (direct stream writes), this makes `Send()` block in the very scenario where it is documented to stay non-blocking.

### Issue Context
- Normal flush rounds snapshot the list under `_deferralGate` and replay outside the lock (good).
- The `MaxFlushRounds` fallback replays under `_deferralGate` (bad for latency/contract).

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[1117-1148]

### What to change
Refactor the fallback so `_deferralGate` is only used for *state transitions/snapshots*, never for replay execution.

Concrete options (pick one and document it):
1) **Non-blocking final drain with a concurrent structure**: replace `List<Action>? _deferredSends` with a `ConcurrentQueue<Action>` (or similar) and keep `_operationInFlight` true while flushing, draining until empty without holding `_deferralGate` across I/O.
2) **Bounded-time final drain**: keep deferral on, but enforce a max elapsed time (Stopwatch) rather than blocking other threads; after budget, log and stop deferring (explicitly documenting that strict ordering during extreme contention is no longer guaranteed).

Whichever strategy you implement, add/adjust tests to ensure:
- `Send()` never blocks, even under pathological contention
- ordering guarantees still match the public contract

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


View more (1)
4. Drain misses in-flight write ✓ Resolved 🐞 Bug ≡ Correctness
Description
DrainOutboundQueueAsync treats IMessageProducer.QueuedMessageCount==0 as “writes have reached the
wire”, but MessageProducer dequeues before performing the synchronous stream write. A text exchange
can then swap consumers while the last write is still in progress, allowing that command’s reply to
be captured as part of the exchange’s response.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1183-1195]

+        private async Task DrainOutboundQueueAsync(CancellationToken cancellationToken)
+        {
+            var producer = _messageProducer;
+            if (producer == null)
+            {
+                return;
+            }
+
+            var deadline = DateTime.UtcNow + OutboundDrainWait;
+            while (producer.QueuedMessageCount > 0 && DateTime.UtcNow < deadline)
+            {
+                await Task.Delay(10, cancellationToken).ConfigureAwait(false);
+            }
Relevance

●●● Strong

Correctness bug affecting reply ownership; similar reliability fixes around text exchange/lifecycle
were accepted recently.

PR-#196
PR-#418

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
DaqifiDevice’s drain loop only checks QueuedMessageCount, but MessageProducer removes items from
the queue before writing them to the stream; therefore the queue can read as empty while a write is
still ongoing, defeating the drain’s purpose before the consumer swap.

src/Daqifi.Core/Device/DaqifiDevice.cs[1170-1196]
src/Daqifi.Core/Communication/Producers/MessageProducer.cs[50-54]
src/Daqifi.Core/Communication/Producers/MessageProducer.cs[164-219]

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

## Issue description
`DrainOutboundQueueAsync` currently waits only for `QueuedMessageCount` to reach 0. In `MessageProducer`, the queue count can become 0 immediately after `TryDequeue` even though the producer thread is still inside the blocking `WriteMessageToStream(...)`. This breaks the intended barrier (“old commands/replies finish before consumer swap”), so a text exchange can still mis-associate replies.

## Issue Context
- The drain is used specifically to prevent a command queued just before a text exchange from having its reply read by the temporary text consumer.
- `QueuedMessageCount` is not a reliable proxy for “no writes in progress”.

## Fix Focus Areas
- Add an explicit “idle / no in-flight write” signal to the producer (e.g., in-flight counter + `IsIdle`/`WaitForIdleAsync`), and have `DrainOutboundQueueAsync` wait for that.
- Keep the change backward-compatible by using a new opt-in interface (e.g., `IIdleAwareMessageProducer`) implemented by `MessageProducer<T>`, with a conservative fallback when not available.

### Files / lines
- src/Daqifi.Core/Device/DaqifiDevice.cs[1183-1196]
- src/Daqifi.Core/Communication/Producers/MessageProducer.cs[164-220]
- src/Daqifi.Core/Communication/Producers/IMessageProducer.cs[9-59]

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



Remediation recommended

5. Test ignores wait timeout ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
Send_ArrivingDuringTheFlush_DoesNotOvertakeAlreadyDeferredMessages ignores the boolean result of
replayStarted.Wait(DeadlockBudget) inside the competitor thread, so the competitor can still send
even when the replay never started. This can turn regressions into slow/nondeterministic failures
and makes the test’s phase boundary unenforced.
Code

src/Daqifi.Core.Tests/Device/DaqifiDeviceOperationSerializationTests.cs[R488-492]

+        var competitor = new Thread(() =>
+        {
+            replayStarted.Wait(DeadlockBudget);
+            device.Send(new TaggedBinaryMessage("B"));
+        })
Relevance

●●● Strong

Team often hardens concurrency tests with bounded/checked waits to avoid nondeterministic
hangs/flakes.

PR-#364
PR-#411

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The competitor thread calls replayStarted.Wait(...) but does not check the result before sending,
so it can proceed after a timeout and invalidate the intended ordering race window.

src/Daqifi.Core.Tests/Device/DaqifiDeviceOperationSerializationTests.cs[483-493]

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 coordination `Wait(...)` inside a manually created `Thread` ignores the returned boolean, so the test continues even when the prerequisite condition wasn’t met.

### Issue Context
This test is intentionally using a raw `Thread` (not `Task.Run`) so it does **not** inherit `AsyncLocal` ownership. That’s fine, but the thread still needs to reliably signal failure back to the main test.

### Fix Focus Areas
- src/Daqifi.Core.Tests/Device/DaqifiDeviceOperationSerializationTests.cs[488-492]

### What to change
- Capture the return value of `replayStarted.Wait(DeadlockBudget)`.
- If it is `false`, record a shared failure flag (or signal a `TaskCompletionSource<Exception?>`) and return without sending.
- After `Join`, assert on the flag / propagate the exception on the main test thread so the test fails immediately and deterministically.

Example pattern:
```csharp
Exception? competitorFailure = null;
var competitor = new Thread(() =>
{
   if (!replayStarted.Wait(DeadlockBudget))
   {
       competitorFailure = new TimeoutException("Replay never started");
       return;
   }
   device.Send(new TaggedBinaryMessage("B"));
});
...
Assert.True(competitor.Join(TimeSpan.FromSeconds(10)));
Assert.Null(competitorFailure);
```

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


6. Deferred sends can reorder ✓ Resolved 🐞 Bug ☼ Reliability
Description
FlushDeferredSends clears _operationInFlight before replaying the parked sends, so concurrent
Send() calls can stop deferring and enqueue immediately while the replay is still running. This
allows new sends to interleave with (and potentially overtake) previously deferred sends.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1096-1122]

+            List<Action>? parked;
+            lock (_deferralGate)
+            {
+                _operationInFlight = false;
+                parked = _deferredSends;
+                _deferredSends = null;
+            }
+
+            if (parked == null)
+            {
+                return;
+            }
+
+            foreach (var send in parked)
+            {
+                try
+                {
+                    send();
+                }
+                catch (Exception ex)
+                {
+                    SafeLog(() => _logger.LogWarning(
+                        ex,
+                        "A message deferred while an exclusive operation was running could not be "
+                        + "sent afterwards; it was dropped."));
+                }
+            }
Relevance

●●● Strong

Ordering is core to #342; team often accepts concurrency/race fixes in DaqifiDevice.

PR-#196
PR-#418

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code explicitly clears _operationInFlight before executing the deferred send actions, and
TryDeferSend only defers when _operationInFlight is true; therefore a concurrent sender can
bypass deferral during the replay window.

src/Daqifi.Core/Device/DaqifiDevice.cs[1094-1123]
src/Daqifi.Core/Device/DaqifiDevice.cs[1144-1160]

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

## Issue description
`FlushDeferredSends()` sets `_operationInFlight=false` under `_deferralGate`, then replays deferred sends outside the gate. During that replay window, other threads will observe `_operationInFlight==false` in `TryDeferSend` and will enqueue immediately, interleaving with the replay and undermining ordering guarantees for deferred messages.

## Issue Context
- `TryDeferSend` uses `_operationInFlight` (not the semaphore) as the deferral decision.
- `_deferralGate` protects list/flag transitions, but does not protect the replay phase.

## Fix Focus Areas
- Keep deferral enabled while replaying, and drain until quiescent:
 - Under `_deferralGate`, swap out the current list but keep `_operationInFlight=true`.
 - Replay outside the gate.
 - Re-check under the gate for any newly deferred sends that arrived during replay; repeat until none remain.
 - Only then set `_operationInFlight=false`.
- Alternatively, introduce a dedicated “flushing” state that still forces `TryDeferSend` to park messages until flush completes.

### Files / lines
- src/Daqifi.Core/Device/DaqifiDevice.cs[1094-1123]
- src/Daqifi.Core/Device/DaqifiDevice.cs[1144-1160]

ⓘ 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 8882518

Results up to commit b782aec ⚖️ Balanced


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


Action required
1. Drain misses in-flight write ✓ Resolved 🐞 Bug ≡ Correctness
Description
DrainOutboundQueueAsync treats IMessageProducer.QueuedMessageCount==0 as “writes have reached the
wire”, but MessageProducer dequeues before performing the synchronous stream write. A text exchange
can then swap consumers while the last write is still in progress, allowing that command’s reply to
be captured as part of the exchange’s response.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1183-1195]

+        private async Task DrainOutboundQueueAsync(CancellationToken cancellationToken)
+        {
+            var producer = _messageProducer;
+            if (producer == null)
+            {
+                return;
+            }
+
+            var deadline = DateTime.UtcNow + OutboundDrainWait;
+            while (producer.QueuedMessageCount > 0 && DateTime.UtcNow < deadline)
+            {
+                await Task.Delay(10, cancellationToken).ConfigureAwait(false);
+            }
Relevance

●●● Strong

Correctness bug affecting reply ownership; similar reliability fixes around text exchange/lifecycle
were accepted recently.

PR-#196
PR-#418

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
DaqifiDevice’s drain loop only checks QueuedMessageCount, but MessageProducer removes items from
the queue before writing them to the stream; therefore the queue can read as empty while a write is
still ongoing, defeating the drain’s purpose before the consumer swap.

src/Daqifi.Core/Device/DaqifiDevice.cs[1170-1196]
src/Daqifi.Core/Communication/Producers/MessageProducer.cs[50-54]
src/Daqifi.Core/Communication/Producers/MessageProducer.cs[164-219]

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

## Issue description
`DrainOutboundQueueAsync` currently waits only for `QueuedMessageCount` to reach 0. In `MessageProducer`, the queue count can become 0 immediately after `TryDequeue` even though the producer thread is still inside the blocking `WriteMessageToStream(...)`. This breaks the intended barrier (“old commands/replies finish before consumer swap”), so a text exchange can still mis-associate replies.

## Issue Context
- The drain is used specifically to prevent a command queued just before a text exchange from having its reply read by the temporary text consumer.
- `QueuedMessageCount` is not a reliable proxy for “no writes in progress”.

## Fix Focus Areas
- Add an explicit “idle / no in-flight write” signal to the producer (e.g., in-flight counter + `IsIdle`/`WaitForIdleAsync`), and have `DrainOutboundQueueAsync` wait for that.
- Keep the change backward-compatible by using a new opt-in interface (e.g., `IIdleAwareMessageProducer`) implemented by `MessageProducer<T>`, with a conservative fallback when not available.

### Files / lines
- src/Daqifi.Core/Device/DaqifiDevice.cs[1183-1196]
- src/Daqifi.Core/Communication/Producers/MessageProducer.cs[164-220]
- src/Daqifi.Core/Communication/Producers/IMessageProducer.cs[9-59]

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



Remediation recommended
2. Deferred sends can reorder ✓ Resolved 🐞 Bug ☼ Reliability
Description
FlushDeferredSends clears _operationInFlight before replaying the parked sends, so concurrent
Send() calls can stop deferring and enqueue immediately while the replay is still running. This
allows new sends to interleave with (and potentially overtake) previously deferred sends.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1096-1122]

+            List<Action>? parked;
+            lock (_deferralGate)
+            {
+                _operationInFlight = false;
+                parked = _deferredSends;
+                _deferredSends = null;
+            }
+
+            if (parked == null)
+            {
+                return;
+            }
+
+            foreach (var send in parked)
+            {
+                try
+                {
+                    send();
+                }
+                catch (Exception ex)
+                {
+                    SafeLog(() => _logger.LogWarning(
+                        ex,
+                        "A message deferred while an exclusive operation was running could not be "
+                        + "sent afterwards; it was dropped."));
+                }
+            }
Relevance

●●● Strong

Ordering is core to #342; team often accepts concurrency/race fixes in DaqifiDevice.

PR-#196
PR-#418

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code explicitly clears _operationInFlight before executing the deferred send actions, and
TryDeferSend only defers when _operationInFlight is true; therefore a concurrent sender can
bypass deferral during the replay window.

src/Daqifi.Core/Device/DaqifiDevice.cs[1094-1123]
src/Daqifi.Core/Device/DaqifiDevice.cs[1144-1160]

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

## Issue description
`FlushDeferredSends()` sets `_operationInFlight=false` under `_deferralGate`, then replays deferred sends outside the gate. During that replay window, other threads will observe `_operationInFlight==false` in `TryDeferSend` and will enqueue immediately, interleaving with the replay and undermining ordering guarantees for deferred messages.

## Issue Context
- `TryDeferSend` uses `_operationInFlight` (not the semaphore) as the deferral decision.
- `_deferralGate` protects list/flag transitions, but does not protect the replay phase.

## Fix Focus Areas
- Keep deferral enabled while replaying, and drain until quiescent:
 - Under `_deferralGate`, swap out the current list but keep `_operationInFlight=true`.
 - Replay outside the gate.
 - Re-check under the gate for any newly deferred sends that arrived during replay; repeat until none remain.
 - Only then set `_operationInFlight=false`.
- Alternatively, introduce a dedicated “flushing” state that still forces `TryDeferSend` to park messages until flush completes.

### Files / lines
- src/Daqifi.Core/Device/DaqifiDevice.cs[1094-1123]
- src/Daqifi.Core/Device/DaqifiDevice.cs[1144-1160]

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


Results up to commit cf16f66 ⚖️ Balanced


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


Action required
1. Flush can block Send ✓ Resolved 🐞 Bug ☼ Reliability
Description
FlushDeferredSends() replays deferred sends while holding _deferralGate after MaxFlushRounds,
so concurrent Send() calls can block on that lock for the duration of potentially blocking I/O
(e.g., direct stream.Write). This violates the documented “Send() never blocks” contract and can
stall caller threads under sustained contention.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1138-1147]

+            lock (_deferralGate)
+            {
+                _operationInFlight = false;
+
+                var remaining = _deferredSends;
+                _deferredSends = null;
+                if (remaining != null)
+                {
+                    ReplayDeferredSends(remaining);
+                }
Relevance

●●● Strong

Repository has strong precedent for fixing lock/teardown/concurrency issues to uphold documented
contracts and avoid stalls.

PR-#196
PR-#418

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new fallback path holds _deferralGate while replaying deferred sends; Send() attempts to
take _deferralGate during deferral checks, so it can block until replay finishes. The replay
delegates call SendNow(...), which can block on synchronous stream writes for non-string payloads.

src/Daqifi.Core/Device/DaqifiDevice.cs[1115-1148]
src/Daqifi.Core/Device/DaqifiDevice.cs[1189-1206]
src/Daqifi.Core/Device/DaqifiDevice.cs[1866-1920]

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

### Issue description
`FlushDeferredSends()` has a fallback path (after `MaxFlushRounds`) that holds `_deferralGate` while calling `ReplayDeferredSends(...)`. Since `Send()` needs `_deferralGate` (via `TryDeferSend`) and deferred sends can perform blocking I/O (direct stream writes), this makes `Send()` block in the very scenario where it is documented to stay non-blocking.

### Issue Context
- Normal flush rounds snapshot the list under `_deferralGate` and replay outside the lock (good).
- The `MaxFlushRounds` fallback replays under `_deferralGate` (bad for latency/contract).

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[1117-1148]

### What to change
Refactor the fallback so `_deferralGate` is only used for *state transitions/snapshots*, never for replay execution.

Concrete options (pick one and document it):
1) **Non-blocking final drain with a concurrent structure**: replace `List<Action>? _deferredSends` with a `ConcurrentQueue<Action>` (or similar) and keep `_operationInFlight` true while flushing, draining until empty without holding `_deferralGate` across I/O.
2) **Bounded-time final drain**: keep deferral on, but enforce a max elapsed time (Stopwatch) rather than blocking other threads; after budget, log and stop deferring (explicitly documenting that strict ordering during extreme contention is no longer guaranteed).

Whichever strategy you implement, add/adjust tests to ensure:
- `Send()` never blocks, even under pathological contention
- ordering guarantees still match the public contract

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



Remediation recommended
2. Test ignores wait timeout ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
Send_ArrivingDuringTheFlush_DoesNotOvertakeAlreadyDeferredMessages ignores the boolean result of
replayStarted.Wait(DeadlockBudget) inside the competitor thread, so the competitor can still send
even when the replay never started. This can turn regressions into slow/nondeterministic failures
and makes the test’s phase boundary unenforced.
Code

src/Daqifi.Core.Tests/Device/DaqifiDeviceOperationSerializationTests.cs[R488-492]

+        var competitor = new Thread(() =>
+        {
+            replayStarted.Wait(DeadlockBudget);
+            device.Send(new TaggedBinaryMessage("B"));
+        })
Relevance

●●● Strong

Team often hardens concurrency tests with bounded/checked waits to avoid nondeterministic
hangs/flakes.

PR-#364
PR-#411

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The competitor thread calls replayStarted.Wait(...) but does not check the result before sending,
so it can proceed after a timeout and invalidate the intended ordering race window.

src/Daqifi.Core.Tests/Device/DaqifiDeviceOperationSerializationTests.cs[483-493]

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 coordination `Wait(...)` inside a manually created `Thread` ignores the returned boolean, so the test continues even when the prerequisite condition wasn’t met.

### Issue Context
This test is intentionally using a raw `Thread` (not `Task.Run`) so it does **not** inherit `AsyncLocal` ownership. That’s fine, but the thread still needs to reliably signal failure back to the main test.

### Fix Focus Areas
- src/Daqifi.Core.Tests/Device/DaqifiDeviceOperationSerializationTests.cs[488-492]

### What to change
- Capture the return value of `replayStarted.Wait(DeadlockBudget)`.
- If it is `false`, record a shared failure flag (or signal a `TaskCompletionSource<Exception?>`) and return without sending.
- After `Join`, assert on the flag / propagate the exception on the main test thread so the test fails immediately and deterministically.

Example pattern:
```csharp
Exception? competitorFailure = null;
var competitor = new Thread(() =>
{
   if (!replayStarted.Wait(DeadlockBudget))
   {
       competitorFailure = new TimeoutException("Replay never started");
       return;
   }
   device.Send(new TaggedBinaryMessage("B"));
});
...
Assert.True(competitor.Join(TimeSpan.FromSeconds(10)));
Assert.Null(competitorFailure);
```

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


Results up to commit 53036b6 ⚖️ Balanced


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


Action required
1. Deferral state not reset ✓ Resolved 🐞 Bug ≡ Correctness
Description
FinishDisconnect() drops the deferred-send backlog but does not clear _operationInFlight, so after a
disconnect that proceeds without acquiring the operation/text-exchange lock, Send() can keep
deferring after a reconnect with no guaranteed drainer (messages are accepted but may never be
delivered). This is especially problematic in the exact scenario Disconnect is designed for: an
in-flight operation that doesn’t complete within the bounded teardown wait.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1221-1226]

+        private void DiscardDeferredSends()
+        {
+            lock (_deferralGate)
+            {
+                _deferredSends = null;
+            }
Relevance

●●● Strong

Team often accepts disconnect/teardown state-reset fixes to prevent stale state across reconnects.

PR-#418
PR-#356

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The teardown path explicitly allows disconnect to proceed without acquiring the operation lock; in
that case an in-flight operation may never run its normal flush/cleanup. With _operationInFlight
left true and _deferredSends nulled, TryDeferSend() will continue to defer by creating a new
queue, but only operation exit paths call FlushDeferredSends() to clear the state and replay, so
there is no guaranteed drainer post-teardown/reconnect.

src/Daqifi.Core/Device/DaqifiDevice.cs[1783-1812]
src/Daqifi.Core/Device/DaqifiDevice.cs[1893-1904]
src/Daqifi.Core/Device/DaqifiDevice.cs[1217-1227]
src/Daqifi.Core/Device/DaqifiDevice.cs[1266-1275]
src/Daqifi.Core/Device/DaqifiDevice.cs[1138-1147]

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

### Issue description
`FinishDisconnect()` now calls `DiscardDeferredSends()` to prevent a deferred backlog from leaking across sessions, but `DiscardDeferredSends()` only clears `_deferredSends` and leaves `_operationInFlight` unchanged. Because `TryDeferSend()` defers when `_operationInFlight` is true even if `_deferredSends` is null, a disconnect that tears down without acquiring the operation lock can leave the device stuck in “deferring” mode after a reconnect, with no guaranteed code path to flush.

### Issue Context
Teardown is explicitly best-effort around `_textExchangeLock` (bounded wait / cancellation), so this state can persist exactly when an operation is wedged and never reaches its `finally` to call `FlushDeferredSends()`.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[1217-1227]
- src/Daqifi.Core/Device/DaqifiDevice.cs[1893-1904]
- src/Daqifi.Core/Device/DaqifiDevice.cs[1266-1274]

### What to change
- In `DiscardDeferredSends()` (or directly in `FinishDisconnect` under the same `_deferralGate`), clear **both** `_deferredSends` and `_operationInFlight` together so teardown fully resets the deferral state.
- Keep the update atomic under `_deferralGate` (same rationale already documented for `_operationInFlight` + `_deferredSends` moving together).

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


Qodo Logo

Comment thread src/Daqifi.Core/Device/DaqifiDevice.cs
Comment thread src/Daqifi.Core/Device/DaqifiDevice.cs Outdated
… round 1)

Both findings were real and both attacked the point of the change.

The outbound drain was not a barrier. It waited for QueuedMessageCount to
reach zero, but MessageProducer takes a message off the queue BEFORE it
writes it, so the count reads zero while the write is still going out. A
text exchange could still take the stream mid-write and collect that
command's reply as its own. MessageProducer now tracks the drain batch
itself and exposes IsIdle ("nothing queued and nothing being written"),
which is what the barrier waits on. IsIdle is a default interface member
carrying the old queue-only answer, so existing implementations compile and
behave unchanged.

Deferred sends could be overtaken. FlushDeferredSends cleared the
"deferring" flag before replaying, so a send arriving mid-replay saw
nothing in flight and went straight out ahead of messages parked before it.
Deferral now stays on for the whole replay, which still runs outside the
gate so Send() keeps not blocking; the flush drains in rounds and only
stops deferring in the same locked moment it observes an empty list. A
sender fast enough to refill every round is bounded by finishing the last
round under the gate.

Both are covered by tests confirmed to fail without their fix — the
ordering one reproduces the exact inversion (A1, B, A2, A3).

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

Copy link
Copy Markdown
Contributor Author

Qodo round 1 — both bugs were real, both fixed in cf16f66

Neither was a false positive, and both were in the synchronization this PR adds. Detailed replies are on the two inline threads; the short version:

1. The drain was not a barrier. It waited for QueuedMessageCount == 0, but MessageProducer takes a message off the queue before it writes it — so the count reads zero while the write is still going out, and a text exchange could still take the stream mid-write and collect that command's reply as its own. That is precisely the failure the drain existed to prevent, so the barrier had a hole straight through the middle of it.

MessageProducer now tracks its drain batch and exposes IsIdle — "nothing queued and nothing being written" — which is what the drain waits on. I used a default interface member (carrying the old queue-only answer) rather than a separate opt-in interface, so existing implementations compile and behave unchanged with no type test at the call site.

2. Deferred sends could be overtaken. FlushDeferredSends cleared the deferring flag before replaying, so a send arriving mid-replay went straight out ahead of messages parked before it. Deferral now stays on across the whole replay, which still runs outside the gate so Send() keeps not blocking; the flush drains in rounds and only stops deferring in the same locked moment it observes an empty list. Bounded at 8 rounds, finishing under the gate, so a hot sender can't spin it.

Both are pinned by tests confirmed to fail without their fix — I reverted each fix in turn and watched the test go red, then restored it:

Test Without the fix
Producer_WithAWriteInFlight_ReportsQueueEmptyButNotIdle IsIdle reported true while a write was still in flight
TextExchange_DoesNotTakeTheStreamWhileAWriteIsStillInFlight took the stream while a command was still being written (sample 0)
Send_ArrivingDuringTheFlush_DoesNotOvertakeAlreadyDeferredMessages Expected ["A1","A2","A3","B"] / Actual ["A1","B","A2","A3"]

Writing the second one honestly cost a round: my first version sampled once at 400 ms and passed even with the fix reverted, because by then the exchange had finished and restarted the consumer. It now samples across the whole window.

Full suite green: 2355 Core tests × net9.0/net10.0, 23 MCP tests.

Still outstanding, unchanged: the WiFi bench leg. USB remains decisive (15/15 replies corrupted on origin/main vs 0/15 here, reproduced three times), but the bench unit's WiFi stack wedged on both attempts and needs a physical power cycle before that leg can be run. These fixes are transport-agnostic, but I am not treating USB as a substitute.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment thread src/Daqifi.Core/Device/DaqifiDevice.cs Outdated
@qodo-code-review

Copy link
Copy Markdown

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

… across I/O (Qodo round 2)

Round 1's bound on the flush was paid for in the wrong currency. Capping
the rounds and finishing the last one under _deferralGate meant a
concurrent Send() could block on replay I/O for as long as that took —
measured at 462ms in the new test — which is exactly the guarantee
deferral was chosen over waiting to provide.

The backlog is now a queue that is drained one message at a time: the
message is dequeued under the gate, replayed outside it, always, with no
final under-gate stretch. What keeps ordering is no longer the flag alone
but the backlog itself — it stays non-null (possibly empty) for the whole
drain, and a send arriving mid-drain sees it and parks behind it instead
of overtaking. It is nulled only in the same locked moment the drain
observes it empty.

Termination is still bounded, but by handing off rather than by blocking:
after 64 messages the rest goes to a background empty exclusive operation,
which is itself a flush. Going through the operation lock is the point — a
bare background replay could write into a text exchange that started
meanwhile. The backlog stays non-null across the handoff, so ordering
survives it.

Teardown now discards parked sends: they were addressed to a session that
no longer exists, and a backlog outliving its drainer would leave the next
session deferring into it.

Also fixes a test that discarded the bool from its phase-boundary wait, so
the competing sender could race a replay that never started and the
ordering assertion would pass vacuously. The result is asserted, and the
wait is shorter than the join that follows so the failure names itself.

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

Copy link
Copy Markdown
Contributor Author

Qodo round 2 — both findings valid, fixed in 53036b6

Both were consequences of my round-1 fixes, and the first one was the more embarrassing: I bought termination with the exact guarantee this design exists to provide.

1. Flush could block Send(). Round 1 bounded the flush at 8 rounds and finished the last one holding _deferralGate — so a concurrent Send(), which takes that same gate, could block on replay I/O. Measured at 462 ms against the round-1 code. The constraint Qodo identified is the right one: the final round cannot both be under the gate and do the writes. So it now does neither — there is no final under-gate stretch at all.

The backlog became a queue drained one message at a time: dequeued under the gate, replayed outside it, always. Ordering is now preserved by the backlog rather than by the flag — it stays non-null for the whole drain, TryDeferSend defers on _operationInFlight || _deferredSends != null, and it is nulled only in the same locked moment the drain observes it empty. A send arriving mid-replay parks behind it instead of overtaking, while the gate is only ever held long enough to insert into a queue.

Termination is still bounded, but by handing off rather than blocking: past 64 messages the remainder goes to a background empty exclusive operation, which is itself a flush. Routing it through the operation lock matters — a bare background replay could write into a text exchange that started meanwhile.

Teardown now also discards parked sends: they belonged to a session that no longer exists, and a backlog outliving its drainer would leave the next session deferring into one nobody drains.

2. Test ignored its wait timeout. Same class of mistake I caught in my own drain test last round, so no defence for it. The competitor now captures the wait result, refuses to send if the replay never started, and the main thread asserts it. The helper wait is deliberately shorter than the join that follows, so a phase that never fires names itself instead of surfacing as a join timeout. Applied to the new probe thread too.

Verified in both directions

Test Against the pre-fix code
Send_DoesNotBlockWhileALargeBacklogIsBeingFlushed Send() blocked for 462ms during the flush
Send_ArrivingDuringTheFlush_DoesNotOvertakeAlreadyDeferredMessages (signal suppressed) fails loudly instead of passing vacuously

Full suite green: 2356 Core × net9.0/net10.0, 23 MCP. The serialization suite was run three extra times to check the timing-sensitive assertions aren't flaky — 18/18 each time.

Unchanged: the WiFi bench leg is still outstanding and the flag stays on this PR. USB remains decisive (15/15 corrupted on origin/main vs 0/15 here); the bench unit's WiFi stack wedged on both attempts and needs a power cycle. Everything in these two rounds was unit-testable, so no hardware was involved in either fix.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment thread src/Daqifi.Core/Device/DaqifiDevice.cs Outdated
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 53036b6

…o round 3)

Last round's teardown change dropped the parked backlog but left the "an
operation owns the device" flag set. That is the same hazard by a different
route, and a worse one: with the flag stale, every Send() on the next
session parks into a fresh backlog that nothing will drain, and because
Send() reports success the moment it parks, the caller is told the command
is on its way while it silently goes nowhere.

Teardown is exactly where it gets stranded. The operation whose exit path
would normally clear the flag is the one that failed to finish inside the
bounded wait — so the disconnect proceeds without the lock and the flag
outlives the session. That is the case Disconnect exists to handle, and it
lands on the reconnect path from #379.

Both halves now reset together under one lock.

Swept the rest of the teardown path. MarkDisconnectedWithoutTeardown — the
route taken when the LIFECYCLE lock is abandoned — is deliberately left
alone: it tears nothing down, so the session is still owned by the stuck
holder, and clearing deferral there would let sends bypass an operation
that is genuinely still running on a live session. It reports Disconnected,
so Send() throws rather than parking, and the stuck holder's own teardown
resets the state when it unwinds.

Test drives the abandoned-lock path specifically; a clean disconnect resets
the flag via the operation's own exit path and would pass either way.
Without the fix it reproduces the silent loss exactly: "'DIO:PORt:STATe'
never reached the wire. Writes: ".

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

Copy link
Copy Markdown
Contributor Author

Qodo round 3 — valid, fixed in 9b08889

This one was my own round-2 reasoning left half-applied. I identified that a backlog outliving its drainer is a hazard, dropped the backlog, and left the flag — which reproduces the identical hazard by the other route, and a worse one.

The failure mode is silent message loss. With the flag stale, every Send() on the next session parks into a fresh backlog with nothing to drain it. Send() reports success the moment it parks, so the caller believes the command is on its way while it goes nowhere — no throw, no log, nothing to notice. And teardown is exactly where it strands: the operation whose exit path would normally clear the flag is precisely the one that failed to finish inside the bounded wait.

Both halves now reset together under one lock.

Why clearing it mid-operation is safe (that being the case it fires in): the wedged operation still holds the semaphore, so no new operation can begin before it releases, and its own exit path runs before that release. It cannot clear the flag out from under a later operation.

Swept the rest of the teardown path, as suggested. One other route reaches "disconnected" without the normal cleanup — MarkDisconnectedWithoutTeardown, taken when the lifecycle lock is abandoned — and I deliberately left it alone. It tears nothing down: the session is still owned by the stuck holder, so clearing deferral there would let sends bypass an operation genuinely still running on a live connection, which is the corruption this PR exists to prevent. It reports Disconnected, so Send() throws rather than parking, and the stuck holder resets the state through the normal path when it unwinds. Reasoning is in the code comment; happy to be argued out of it.

Nothing else in the teardown path carries per-session state that the lock-held route resets and the abandoned route skips — _isDisconnecting, _isInitialized, Status and State are all set unconditionally, and the producer/consumer are nulled by StopMessagePumps on both.

Verified

The test drives the abandoned-lock path specifically. A clean disconnect resets the flag via the operation's own exit path and would pass either way, so it would have proved nothing. Without the fix:

'DIO:PORt:STATe' never reached the wire. Writes: 

Full suite green: 2357 Core × net9.0/net10.0, 23 MCP.

Unchanged: the WiFi bench leg is still outstanding and the flag stays on this PR. All three review rounds have been unit-testable, so no hardware was involved in any of them.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 9b08889

@tylerkron

Copy link
Copy Markdown
Contributor Author

WiFi leg complete — the last outstanding item on this PR

Run after a power cycle, branch first. The PR body now carries the full before/after for both transports; the caveat about re-running WiFi is gone because it has been run.

WiFi (192.168.1.30, TCP 9760), origin/main → this branch:

origin/main this branch
Queries answered at all 13 of 15 (2 threw) 15 of 15
Replies that came back empty 11 0
Stray lines mixed into replies 36 1
Indivisible sequences n/a 48/48, 0 overlaps, no deadlock
Stream continuity never reached — device dropped 742 samples, 0 silent seconds, 1287 control ops, 0 errors

The headline over TCP is not the pollution, it is the 11 empty replies on main — the query's own answer never arrived. TCP chunks the interleaved traffic differently from USB CDC, so a command written into the exchange window doesn't merely add a line, it can swallow the reply the caller was waiting for. Every query got its answer on this branch.

Nothing deadlocked at the higher and far more variable latency (RTT 29–215 ms), and a live stream kept flowing while three threads drove DIO — the property the operation lock must never break.

The one imperfect number, since I would rather point at it than have someone find it: the branch still shows a single stray line, ["0,\"No error\"", "0"]. That is the fire-and-forget-query residual already documented in the PR, not a serialization failure — note the ordering. The stray arrives before the real answer, so it is a late reply to an earlier deferred command, which a ~100 ms round trip makes far likelier to land in the next query's window than USB's microseconds. On main the pattern is inverted: the real answer followed by foreign lines written into the window.

A pattern I am explicitly not claiming as a result: both origin/main WiFi runs ended with the unit dropping off the network during the streaming phase, while both branch runs completed every phase. This unit's WiFi stack is known to wedge under cumulative connect churn, main was the second connect of each session, and two samples is not evidence. Recording it so it isn't mistaken for a clean sweep.

After the drop the unit still passed a full run over USB (15/15 clean, 0 stray lines, streaming fine, empty error queue), so the transport wedged, not the device. It did come back reporting 16 channels instead of 32 — digital channels missing — which is a device-side artifact of the wedge and clears on a power cycle. The WiFi stack will need another power cycle before the next agent's WiFi run.

No code changed for any of this. The PR is unchanged since 9b08889.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

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

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 9b08889

… (Qodo round 4)

The hazard is real: a flow that acquired the operation lock and then had the
transport torn down and replaced underneath it kept bypassing deferral,
writing straight into the new session while an unrelated operation owned it.

But the cause was not the unconditional reset, and the proposed remedy —
gating that reset on teardown having acquired the lock — makes things
strictly worse. TryDeferSend asks about ownership BEFORE it looks at
_operationInFlight, so the stale flow's bypass never consulted that flag at
all. Leaving the flag set does not quiet the stale flow; it only silences
every other flow, whose sends then park in a backlog with no drainer. That
is round 3's silent loss plus the stale writer, and the round-3 test
reproduces it verbatim against the proposed change.

What was actually wrong was the ownership predicate. Holding the semaphore
and owning the current session are different questions, and teardown is
where they diverge. Ownership is now stamped with a session generation that
every teardown retires:

- Re-entrancy keeps asking the session-blind question. A flow that holds the
  semaphore must never be told to wait for it — ExecuteTextCommandAsync
  waits without a timeout, so a wrong answer there is a hang, not a
  degradation.
- Send()'s bypass asks the session-scoped one. A flow from a retired session
  is not the owner of the current one and queues behind its rules.

This also settles the round-3 asymmetry, which was right in outcome and
wrong in reasoning. The principle is not "is an operation running" but "did
the session end". FinishDisconnect: it ended, so the deferral state
describes something that no longer exists — reset unconditionally.
MarkDisconnectedWithoutTeardown: nothing was torn down, the stuck holder
still owns a live transport, so the state is still live — leave it.

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

Copy link
Copy Markdown
Contributor Author

Qodo round 4 — hazard real, attribution wrong, fixed at the root in 8882518

Three separate claims here, and they don't all land the same way.

1. My round-3 safety argument was incomplete — conceded. I argued "that operation still holds the semaphore, so no new operation can begin before it releases." That is about new operations. Plain Send() calls from other flows never touch the semaphore, so the argument never covered them.

2. The hazard is real. A flow that acquired the operation lock and then had the transport torn down and replaced underneath it kept bypassing deferral, writing straight into the new session while an unrelated operation owned it.

3. But the proposed remedy does not fix it, and makes things worse. TryDeferSend asks the ownership question before it looks at _operationInFlight — the stale flow's bypass never consults that flag. Leaving it set doesn't quiet the stale flow; it only silences every other flow, whose sends then park in a backlog with no drainer. That is round 3's silent loss plus the stale writer still talking. I applied the suggested conditional reset and ran the round-3 test against it — it fails verbatim: 'DIO:PORt:STATe' never reached the wire. Writes:.

The actual cause, and the fix

The ownership predicate. Holding the semaphore and owning the current session are different questions, and teardown is exactly where they diverge. Ownership is now stamped with a session generation that every teardown retires:

Question Predicate Why
Re-entrancy — may this flow proceed without acquiring? session-blind A flow that holds the semaphore must never be told to wait for it. ExecuteTextCommandAsync waits without a timeout, so a wrong answer is a hang, not a degradation.
Authority — may this flow skip deferral? session-scoped Exclusivity is a property of a session. A flow from a retired session has no claim on the new one.

Settling the round-3 asymmetry

This is the part worth recording, since it was the real question. The round-3 conclusions were right in outcome and wrong in reasoning. The principle is not "is an operation running" — it is "did the session end":

  • FinishDisconnect — it ended. The deferral state describes something that no longer exists, so reset unconditionally. The generation bump is what makes that safe.
  • MarkDisconnectedWithoutTeardown — nothing was torn down; the stuck holder still owns a live transport. The state is still live, so leave it.

Both calls follow from one rule, which is what was missing before.

Verified

Send_FromAFlowThatOutlivedItsSession_NoLongerBypassesDeferral drives the documented fan-out path across a teardown and reconnect, with a genuine operation owning the new session. Without the fix the stale flow writes straight through:

Assert.DoesNotContain() Failure: Filter matched in collection
Collection: ["DIO:PORt:STATe 5,1\r\n"]

Full suite green: 2358 Core × net9.0/net10.0, 23 MCP. Serialization suite run three extra times for flakiness — 20/20 each.

Bench evidence is unaffected — this changes which flows may skip deferral, not the serialization the WiFi and USB runs measured, and no hardware was involved.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

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

@tylerkron
tylerkron merged commit da947af into main Aug 2, 2026
1 check passed
@tylerkron
tylerkron deleted the feature/342-per-device-operation-serialization 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.

investigate: per-device operation serialization for concurrent consumers

1 participant