fix(device): parse protobuf field 22 so analog IsEnabled tracks the device (closes #409) - #411
Conversation
…evice, not just Core's own commands (closes #409) Core previously only ever set analog-channel IsEnabled from its own EnableChannel/DisableChannel calls; the device's own enabled-channel report (analog_in_port_enabled, field 22) was parsed by nothing. PopulateAnalogChannels now reads it as a bit-packed per-channel mask and resyncs IsEnabled from it on every status frame that reports one, so Core's view can't silently drift from the device's — the drift DeviceCapabilities.CurrentMaximumRateHz staleness (#404) depends on. Bench-verified against a real Nq1 (fw 3.7.2): the 16-channel status message reports the mask as 2 little-endian bytes (not one byte per channel, as the field's "list" doc-comment might suggest), matching the layout Core already sends outbound via EnableAdcChannels. Also verified the drift-resync path directly: forcing Core's local IsEnabled out of sync with the device (without notifying it) gets corrected back on the next status frame.
PR Summary by QodoSync analog channel IsEnabled from device-reported enabled bitmask (field 22)
AI Description
Diagram
High-Level Assessment
Files changed (2)
|
Code Review by Qodo
1.
|
…ged on #411 PopulateChannelsFromStatus resyncing analog IsEnabled from the device (field 22) added a second, unsynchronized writer to that field: the channel-management API (EnableChannel/DisableChannel/DisableAllChannels) mutates IsEnabled and then reads it back to compute the outbound ADC/DIO mask, with no lock spanning the two steps. A status frame landing in that gap could revert the mutation before the mask read, silently dropping the just-requested channel from the SCPI command sent to the device. Add DaqifiDevice.WithChannelsLock, reusing the same lock that already guards the status-resync write, and use it to make the mutate-then- compute step in SetChannelsEnabled/DisableAllChannels atomic with respect to a concurrent status frame. The SCPI send itself stays outside the lock. Added a regression test that hammers a concurrent status resync against repeated EnableChannel calls — confirmed it fails without the lock and passes with it.
|
Addressed the IsEnabled resync race flagged above: /agentic_review |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 42fc002 |
The status-resync loop in the #409 race regression test looped with no yield at all, pegging a CPU core for the duration of the test. Add a periodic Thread.Yield() (every 64th iteration, not every iteration — yielding every iteration spaces status frames out enough to stop reliably landing inside the now much-shorter enable/disable critical section, silently weakening the regression coverage) and a 30s safety timeout so a reintroduced deadlock fails the test instead of hanging CI. Verified 5/5 runs still fail without the lock fix and pass with it.
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit a2dd5ad |
…he test instead of hanging CI Cancelling the CTS only stops the loop between iterations; it can't interrupt a PopulateChannelsFromStatus call already in progress. If a regression ever reintroduced a real deadlock there, the unbounded `await resyncTask` in cleanup would hang forever instead of failing. Bound it with WaitAsync(5s) and fail with a clear message on timeout.
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 3a0eaf6 |
… gate waits Two findings from round two. Automatic reconnect introduced a second thread that opens and closes the transport, and cancellation is not synchronization: SupersedeReconnect asks the loop to stop and returns, but a loop already inside a blocking transport connect runs to completion regardless. A caller's Disconnect could therefore be closing the same serial port while the reconnect was opening it, and both threads could build and start a message consumer, leaving two readers on one stream. ConnectCore and DisconnectCore now run under a reentrant lifecycle monitor. Scoped deliberately to the lifecycle pair, not the general per-device operation serialization of #342: it is an internal invariant that the device never drives its own transport from two threads at once, it touches no public behaviour when uncontended, and it leaves the _textExchangeLock ordering question untouched. Reentrant because both methods raise StatusChanged from inside their critical section and a handler calling Disconnect from there must keep working. On timeout it proceeds unsynchronized, which is exactly what shipped before. The scripted test transport and device parked background threads on gate waits of 30s while the assertion timeout is 15s, so a failing test could leave a thread inside the transport long after it gave up. Bounded to 5s, matching the background-wait convention in #364 and #411. Regression tests verified against the pre-fix code: the race reproduces as "a caller's Disconnect was inside the transport at the same time as the reconnect's connect". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
analog_in_port_enabled(protobuf field 22) — analog channelIsEnabledwas only ever set by Core's ownEnableChannel/DisableChannelcalls, so it could silently drift from the device's actual state (another session enabling a channel, state surviving a reconnect, a partially-applied command).PopulateAnalogChannelsnow parses field 22 and resyncsIsEnabledfrom it on every status frame that reports one — both for newly-created channels and for existing channel instances reused across a repopulation.This closes scope item 7 of #390, split out because #390 was closed by #404 with item 7 deliberately deferred. It directly addresses the staleness #404 introduced:
DeviceCapabilities.CurrentMaximumRateHzis computed by the device from its enabled set, and Core's cached copy is only as good as Core's own view of that set.Bench verification
The issue explicitly flagged the wire encoding of field 22 as unverified, so I confirmed it against a real Nq1 (fw 3.7.2) before committing to an implementation:
analog_in_port_type) was wrong. The bench showed the real encoding is a bit-packed mask, same layout Core already sends outbound viaEnableAdcChannels: enabling channels 0 and 2 on a 16-channel device producedAnalogInPortEnabled = [5, 0](2 bytes, little-endian, bit n = channel n), not a 16-byte array.IsEnabled = truewithout telling the device (no SCPI sent), then requested a fresh status frame — Core's view snapped back tofalse, matching the device's real state.Test plan
dotnet test— full suite passes (2184 tests, 2 pre-existing skips)ChannelPopulationTests.cscovering: single-byte mask, multi-byte mask (channel 9+), missing field 22 (old firmware) defaults to disabled, repopulation resyncs from device rather than preserving Core's stale value, truncated mask treats missing bytes as disabled🤖 Generated with Claude Code