refactor(device): extract the text-exchange engine into a collaborator (part of #344) - #479
Conversation
…r (part of #344) `ExecuteTextCommandCoreAsync` and the raw-capture consumer swap were the largest remaining block in `DaqifiDevice` — ~610 lines of the most order-sensitive code in the device, and the primitive every non-streaming operation is built on. They now live in `TextExchangeEngine`, reached through a new internal `ITextExchangeHost` that `DaqifiDevice` implements explicitly. `DaqifiDevice.cs` 3,961 -> 3,615 lines. No public or protected API change: the `ExecuteTextCommandAsync` overloads and `ExecuteRawCaptureAsync` keep their signatures and stay `virtual`, so subclass and test-double overrides remain in the path. The load-bearing subtlety is that three host members write `AsyncLocal` slots — the operation-lock claim, its release, and the re-entrancy flag. An `AsyncLocal` write reaches the writing frame's callees and never its caller, so those members must stay synchronous and be called from the engine's own frame; the engine is what then invokes the caller's prepare/setup/finalize callbacks, which are exactly who must observe them. Both the interface and the implementations say so. Two tests are added because that subtlety turned out to be uncovered: mutating either write to happen behind an `await` left the whole suite green. - `Send_FromInsideATextExchange_GoesStraightOut` — a send from the exchange's own setup action must reach the wire during the exchange, not be parked by the deferral the exchange just switched on. The existing coverage only had the `RunExclusiveAsync` half of this. - `TextExchange_ReEnteredFromItsOwnSetupAction_IsRejected` — drives the real guard instead of planting the flag by reflection. Getting it wrong does not deadlock; the nested call finds the lock held by its own flow, declines to wait, and runs a second consumer swap mid-swap. One intentional deviation from a pure move: the consumer is now snapshotted once in `SuspendInboundConsumer` rather than read four times. The field is mutable and teardown proceeds once its bounded courtesy wait expires, so the original could detach one instance and stop another, or dereference null. This mirrors what `RestartMessageConsumerAfterSwap` already did deliberately, for the same reason. Tests: 2,893 passed / 0 failed on net9.0 and net10.0. Public+protected API surface dumped by reflection and diffed against the merge base — byte-identical. Bench-validated on Nq1 (fw 3.7.2, USB): LAN chip info, SD listing (45 files), SD storage and a streaming run all byte-identical to a build of `origin/main` on the same hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoRefactor: extract device text-exchange into TextExchangeEngine collaborator
AI Description
Diagram
High-Level Assessment
Files changed (4)
|
Code Review by Qodo🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)
Great, no issues found!Qodo reviewed your code and found no material issues that require reviewTip of the day💡 Did you know, you can reply 'qodo' on any finding to push back, ask questions, or dig deeper Previous review resultsReview updated until commit 330d4a3 Results up to commit 330d4a3
|
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 330d4a3 |
The next slice of #344.
ExecuteTextCommandCoreAsyncplus the raw-capture consumer swap were the largest remaining block inDaqifiDevice— and the primitive every non-streaming operation is built on: the SD card operations, the diagnostics, the LAN chip info and the confirming administration commands all reach it throughIDeviceOperationHost.ExecuteTextCommandAsync.They now live in
TextExchangeEngine, reached through a new internalITextExchangeHostthatDaqifiDeviceimplements explicitly — the same arrangementIDeviceOperationHostalready uses.DaqifiDevice.cs3,961 → 3,615 lines.No API change
The
ExecuteTextCommandAsyncoverloads andExecuteRawCaptureAsynckeep their signatures and stayvirtual, so the subclass and test-double overrides that intercept device I/O remain in the path.Verified rather than asserted: I dumped the public + protected surface of both assemblies by reflection (
MetadataLoadContext) and diffed against the merge base — byte-identical, 2,035 members.The subtle part, and why it needed new tests
Three host members write
AsyncLocalslots: the operation-lock claim, its release, and the re-entrancy flag. AnAsyncLocalwrite reaches the writing frame's callees and never its caller, so those members have to stay synchronous and be called from the engine's own frame — the engine is what then invokes the caller's prepare/setup/finalize callbacks, which are exactly who must observe them. This is the same constraintRunExclusiveAsyncobserves by assigning its slot inline instead of in a helper. Both the interface and the implementations say so, at length, because making one of themasync Taskwould compile and read fine.I checked whether that was actually covered by mutating each write to happen behind an
await. Both mutations left the entire suite green. So two tests are added:Send_FromInsideATextExchange_GoesStraightOut— a send from the exchange's own setup action must reach the wire during the exchange, not be parked by the deferral the exchange just switched on. The existing suite had theRunExclusiveAsynchalf of this (Send_FromTheOwningFlow_GoesStraightOut) but not the exchange's own. Broken, the command reaches the device only after the exchange closes, so the exchange collects nothing and returns an empty result — indistinguishable from a silent device.TextExchange_ReEnteredFromItsOwnSetupAction_IsRejected— drives the real guard instead of planting the flag by reflection, which only exercised the reading half. Worth pinning because getting it wrong does not deadlock: the nested call finds the lock already held by its own flow, declines to wait for it, and runs a second consumer swap on a stream mid-swap — the framing corruption the guard exists to prevent, failing silently as mangled replies.Each new test fails against its corresponding mutation and passes against the real thing.
One intentional deviation from a pure move
SuspendInboundConsumersnapshots_messageConsumeronce instead of reading it four times. The field is mutable and teardown proceeds once its bounded courtesy wait expires, so the original could detach one instance and stop another, or dereference null. This mirrors whatRestartMessageConsumerAfterSwapnext to it already did deliberately, and for the reason its remarks already gave. Flagging it because it is the one line that is not a mechanical redirection — happy to revert it to the literal original if you would rather keep this strictly pure.Everything else is mechanical:
_transport→ a local captured once after validation (the field isreadonly),SafeLog(() => _logger…)→ the engine's ownLog, and each piece of device state → its host member. I diffed the moved bodies against the originals comment-stripped to confirm nothing else changed.Testing
2,893 passed / 0 failed / 2 skipped, on net9.0 and net10.0.
Bench-validated on Nq1 (fw 3.7.2, USB,
/dev/cu.usbmodem1101), non-destructive throughout — no flash, no format, no delete, no config write. The example CLI was built against this worktree's core and against a build oforigin/main, and the two were compared on the same hardware:--lan-chip-infoorigin/main--sd-list--sd-storage--show-status+ streamThe LAN chip info run is the load-bearing one: it drives a complete real exchange through the extracted engine — lock acquisition, outbound drain, protobuf consumer stop, text consumer swap, stale-line boundary, setup send, two-phase collection, consumer restart, lock release — and parses a correct non-trivial answer.
One anomaly, reported rather than explained. The very first SD query of the session returned
SdCardNotPresentExceptionon a card that was demonstrably present. It did not reproduce in 9 further attempts on this branch or 4 onorigin/main, including a deliberate stream-then-SD sequence (the known #703 buffer-collapse shape) run alternately on both builds 3× each — 6/6 identical. I could not attribute it to this change, and I could not explain it either; recording it so it is not lost.Notes
DaqifiDevice's behaviour, not onTextExchangeEnginein isolation.Part of #344.
🤖 Generated with Claude Code