From 94735400fd3a17a25fac096856a725f1b255b079 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Thu, 30 Jul 2026 09:36:56 -0600 Subject: [PATCH 1/4] fix(device): parse protobuf field 22 so analog IsEnabled tracks the device, not just Core's own commands (closes #409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Device/ChannelPopulationTests.cs | 121 ++++++++++++++++++ src/Daqifi.Core/Device/DaqifiDevice.cs | 35 ++++- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/ChannelPopulationTests.cs b/src/Daqifi.Core.Tests/Device/ChannelPopulationTests.cs index 3fd912f8..e72785fe 100644 --- a/src/Daqifi.Core.Tests/Device/ChannelPopulationTests.cs +++ b/src/Daqifi.Core.Tests/Device/ChannelPopulationTests.cs @@ -416,6 +416,127 @@ public void PopulateChannelsFromStatus_RepopulatingWithReportedResolution_Clears Assert.Equal(4095u, ((IAnalogChannel)newAnalog0).Resolution); } + [Fact] + public void PopulateChannelsFromStatus_WithAnalogInPortEnabled_SetsIsEnabledFromDevice() + { + // Arrange - device reports channels 0 and 2 enabled, 1 and 3 disabled via a bit-packed + // mask (byte 0b00000101 = 5): confirmed on the bench against a real Nq1 (fw 3.7.2), + // whose 16-channel status reported AnalogInPortEnabled as 2 bytes, [5,0], after enabling + // channels 0 and 2 — the same little-endian bit-per-channel layout Core sends outbound + // via EnableAdcChannels, not a byte-per-channel array (#409). + var device = new DaqifiDevice("TestDevice"); + var message = new DaqifiOutMessage + { + AnalogInPortNum = 4, + AnalogInRes = 65535, + AnalogInPortEnabled = Google.Protobuf.ByteString.CopyFrom(0b0000_0101) + }; + + // Act + device.PopulateChannelsFromStatus(message); + + // Assert + var analogChannels = device.Channels.Where(c => c.Type == ChannelType.Analog).ToList(); + Assert.True(analogChannels[0].IsEnabled); + Assert.False(analogChannels[1].IsEnabled); + Assert.True(analogChannels[2].IsEnabled); + Assert.False(analogChannels[3].IsEnabled); + } + + [Fact] + public void PopulateChannelsFromStatus_WithAnalogInPortEnabledSpanningMultipleBytes_SetsIsEnabledFromDevice() + { + // Arrange - a device with more than 8 analog channels packs the mask across multiple + // bytes; channel 9 is bit 1 of byte 1. Mirrors the bench-observed 2-byte mask for a + // 16-channel Nq1. + var device = new DaqifiDevice("TestDevice"); + var message = new DaqifiOutMessage + { + AnalogInPortNum = 16, + AnalogInRes = 65535, + AnalogInPortEnabled = Google.Protobuf.ByteString.CopyFrom(0b0000_0000, 0b0000_0010) + }; + + // Act + device.PopulateChannelsFromStatus(message); + + // Assert + var analogChannels = device.Channels.Where(c => c.Type == ChannelType.Analog).ToList(); + Assert.False(analogChannels[0].IsEnabled); + Assert.True(analogChannels[9].IsEnabled); + Assert.False(analogChannels[10].IsEnabled); + } + + [Fact] + public void PopulateChannelsFromStatus_WithoutAnalogInPortEnabled_DefaultsToDisabled() + { + // Arrange - older firmware never populates field 22; an empty byte string must not be + // read as "every channel disabled by the device" but simply "not reported". + var device = new DaqifiDevice("TestDevice"); + var message = new DaqifiOutMessage + { + AnalogInPortNum = 2, + AnalogInRes = 65535 + }; + + // Act + device.PopulateChannelsFromStatus(message); + + // Assert + var analogChannels = device.Channels.Where(c => c.Type == ChannelType.Analog).ToList(); + Assert.All(analogChannels, c => Assert.False(c.IsEnabled)); + } + + [Fact] + public void PopulateChannelsFromStatus_RepopulatingWithAnalogInPortEnabled_ResyncsExistingChannelFromDevice() + { + // A later status refresh must resync IsEnabled from the device's report rather than + // preserving whatever Core last set locally, so Core's view can't drift from the + // device's (#409). + var device = new DaqifiDevice("TestDevice"); + var message = new DaqifiOutMessage { AnalogInPortNum = 2, AnalogInRes = 65535 }; + device.PopulateChannelsFromStatus(message); + + var analog0 = device.Channels.First(c => c.Type == ChannelType.Analog && c.ChannelNumber == 0); + analog0.IsEnabled = true; // Core enabled it locally. + + // Act - the device reports channel 0 as actually disabled. + var refreshed = new DaqifiOutMessage + { + AnalogInPortNum = 2, + AnalogInRes = 65535, + AnalogInPortEnabled = Google.Protobuf.ByteString.CopyFrom(0b0000_0000) + }; + device.PopulateChannelsFromStatus(refreshed); + + // Assert - same instance, reused, but IsEnabled now reflects the device. + var newAnalog0 = device.Channels.First(c => c.Type == ChannelType.Analog && c.ChannelNumber == 0); + Assert.Same(analog0, newAnalog0); + Assert.False(newAnalog0.IsEnabled); + } + + [Fact] + public void PopulateChannelsFromStatus_WithShorterAnalogInPortEnabled_TreatsMissingByteAsDisabled() + { + // Arrange - device reports fewer enabled-mask bytes than channels need (e.g. a truncated + // frame): channels 8+ have no byte to read and must default to disabled rather than throw. + var device = new DaqifiDevice("TestDevice"); + var message = new DaqifiOutMessage + { + AnalogInPortNum = 9, + AnalogInRes = 65535, + AnalogInPortEnabled = Google.Protobuf.ByteString.CopyFrom(0b0000_0001) + }; + + // Act + device.PopulateChannelsFromStatus(message); + + // Assert + var analogChannels = device.Channels.Where(c => c.Type == ChannelType.Analog).ToList(); + Assert.True(analogChannels[0].IsEnabled); + Assert.All(analogChannels.Skip(1), c => Assert.False(c.IsEnabled)); + } + #endregion #region Digital Channel Population diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs index 64a347cf..b41b68fe 100644 --- a/src/Daqifi.Core/Device/DaqifiDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiDevice.cs @@ -1778,6 +1778,9 @@ private static void SafeLog(Action logAction) /// For analog channels, calibration parameters (CalM, CalB, InternalScaleM, PortRange) /// are extracted from the message. If there's a mismatch between the declared channel /// count and the available calibration data, default values are used for missing parameters. + /// Analog channel IsEnabled is likewise taken from the device-reported enabled mask + /// (field 22, analog_in_port_enabled) when the message carries one, so it reflects + /// the device's own view rather than only what Core previously commanded. /// /// For digital channels, only the channel count is used to create instances. /// @@ -1805,8 +1808,11 @@ public virtual void PopulateChannelsFromStatus(DaqifiOutMessage message) { // Index existing channels by identity (type, number). Channels whose identity // is unchanged are updated in place rather than replaced below, so consumer-held - // IChannel references — and the configuration on them (enable/direction/output/ - // PWM state) — survive a routine status re-population untouched. + // IChannel references — and the configuration on them (direction/output/PWM + // state) — survive a routine status re-population untouched. IsEnabled is the + // exception: analog channels resync it from the device-reported enabled mask + // (field 22) whenever the device sends one, so Core's view cannot silently drift + // from the device's (#409). var existingByKey = new Dictionary<(ChannelType, int), IChannel>(); foreach (var existing in _channels) { @@ -1857,6 +1863,12 @@ private int PopulateAnalogChannels(DaqifiOutMessage message, Dictionary<(Channel var analogInCalibrationMValues = message.AnalogInCalM; var analogInInternalScaleMValues = message.AnalogInIntScaleM; var analogInResolution = message.AnalogInRes; + var analogInPortEnabled = message.AnalogInPortEnabled; + + // Firmware before v3.5.0 never populates this field, so an empty byte string is + // ambiguous between "no channels enabled" and "not reported". Only trust it as the + // source of truth for IsEnabled when the device actually sent something. + var enabledIsReported = analogInPortEnabled.Length > 0; var count = (int)message.AnalogInPortNum; @@ -1894,6 +1906,10 @@ private int PopulateAnalogChannels(DaqifiOutMessage message, Dictionary<(Channel if (existingByKey.TryGetValue((ChannelType.Analog, i), out var existing) && existing is AnalogChannel existingAnalog) { existingAnalog.UpdateScalingFromStatus(resolution, calibrationB, calibrationM, internalScaleM, portRange, resolutionIsAssumed); + if (enabledIsReported) + { + existingAnalog.IsEnabled = IsChannelBitSet(analogInPortEnabled, i); + } destination.Add(existingAnalog); continue; } @@ -1902,7 +1918,7 @@ private int PopulateAnalogChannels(DaqifiOutMessage message, Dictionary<(Channel { Name = $"AI{i}", Direction = ChannelDirection.Input, - IsEnabled = false, + IsEnabled = enabledIsReported && IsChannelBitSet(analogInPortEnabled, i), CalibrationB = calibrationB, CalibrationM = calibrationM, InternalScaleM = internalScaleM, @@ -1994,6 +2010,19 @@ private int PopulateDigitalChannels(DaqifiOutMessage message, Dictionary<(Channe return count; } + /// + /// Reads bit from a device-reported per-channel enable + /// bitmask (analog_in_port_enabled, field 22 — confirmed bit-packed on the bench: 2 bytes + /// for 16 channels, little-endian, bit n = channel n — the same layout Core + /// sends outbound via ). + /// Returns false when the channel number falls outside the bytes actually sent. + /// + private static bool IsChannelBitSet(Google.Protobuf.ByteString mask, int channelNumber) + { + var byteIndex = channelNumber / 8; + return byteIndex < mask.Length && (mask[byteIndex] & (1 << (channelNumber % 8))) != 0; + } + /// /// Gets a value from a list with a default fallback if the index is out of range. /// From 42fc002ed2be239ec7197fd8c45f9415dcaea539 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Thu, 30 Jul 2026 09:58:40 -0600 Subject: [PATCH 2/4] fix(device): close the enable/disable-vs-status-resync race Qodo flagged on #411 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...fiStreamingDeviceChannelManagementTests.cs | 54 +++++++++ src/Daqifi.Core/Device/DaqifiDevice.cs | 29 +++++ .../Device/DaqifiStreamingDevice.cs | 114 ++++++++++++++---- 3 files changed, 173 insertions(+), 24 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs index d155768a..3556f930 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs @@ -7,6 +7,8 @@ using System.Collections.Generic; using System.Linq; using System.Net; +using System.Threading; +using System.Threading.Tasks; using Xunit; namespace Daqifi.Core.Tests.Device @@ -239,6 +241,58 @@ public void DisableChannel_LastDigital_SendsGlobalDioDisable() #endregion + #region Concurrent status resync (#409) + + [Fact] + public async Task EnableChannel_ConcurrentWithStatusResync_AlwaysSendsMaskIncludingJustEnabledChannel() + { + // Regression test for #409: PopulateChannelsFromStatus now resyncs analog IsEnabled + // from the device's reported mask on the consumer thread. Without a shared critical + // section, a status frame could interleave between EnableChannel's IsEnabled mutation + // and the ADC mask it computes and sends, silently dropping the just-enabled channel + // from the outbound mask. Hammer a concurrent status resync (reporting everything + // disabled) against repeated EnableChannel calls and assert the sent mask always + // reflects what was just requested. + var device = CreateConnectedDevice(analogChannels: 2, digitalChannels: 0); + var channel0 = AnalogChannelAt(device, 0); + var allDisabledStatus = new DaqifiOutMessage + { + AnalogInPortNum = 2, + AnalogInRes = 65535, + AnalogInPortEnabled = Google.Protobuf.ByteString.CopyFrom(0b0000_0000) + }; + + using var stop = new CancellationTokenSource(); + var resyncTask = Task.Run(() => + { + while (!stop.IsCancellationRequested) + { + device.PopulateChannelsFromStatus(allDisabledStatus); + } + }); + + try + { + for (var i = 0; i < 500; i++) + { + device.DisableAllChannels(); + device.SentMessages.Clear(); + + device.EnableChannel(channel0); + + var sent = Assert.Single(device.SentMessages); + Assert.Equal(ScpiMessageProducer.EnableAdcChannels("1").Data, sent.Data); + } + } + finally + { + stop.Cancel(); + await resyncTask; + } + } + + #endregion + #region DisableAllChannels [Fact] diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs index b41b68fe..1ec9839e 100644 --- a/src/Daqifi.Core/Device/DaqifiDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiDevice.cs @@ -270,6 +270,35 @@ public IReadOnlyList GetChannelsSnapshot() /// protected IReadOnlyList SnapshotChannels() => GetChannelsSnapshot(); + /// + /// Runs under the same lock that guards structural access to + /// and the status-driven IsEnabled resync in + /// . A subclass's channel-management API (e.g. + /// enable/disable) should mutate and compute any derived + /// outbound state (an ADC/DIO enable mask) inside this critical section, so a concurrent + /// status frame on the consumer thread cannot interleave between the mutation and the + /// read that derives the mask — which would send a mask computed from a value the status + /// frame is about to overwrite (#409). Callers must perform blocking I/O (e.g. Send) + /// outside this method; the lock is reentrant, so calling + /// from within is safe. + /// + protected T WithChannelsLock(Func action) + { + lock (_channelsLock) + { + return action(); + } + } + + /// + protected void WithChannelsLock(Action action) + { + lock (_channelsLock) + { + action(); + } + } + /// /// Gets the device's timestamp clock frequency in Hz. /// Populated from the TimestampFreq field of the status message. diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index abe202b5..d2fcaa7a 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -731,14 +731,34 @@ public void DisableAllChannels() throw new DeviceNotConnectedException(); } - foreach (var channel in SnapshotChannels()) + (bool HasChannels, uint Mask) adcMask = default; + (bool HasChannels, bool AnyEnabled) dioState = default; + + // Mutate and derive the outbound masks in one critical section — see the matching + // comment in SetChannelsEnabled (#409) for why the gap matters. + WithChannelsLock(() => { - channel.IsEnabled = false; - } + foreach (var channel in SnapshotChannels()) + { + channel.IsEnabled = false; + } + + adcMask = ComputeAdcEnableMask(); + dioState = ComputeDioEnableState(); + }); // Push the cleared state for whichever channel types this device actually has. - SendAdcEnableMask(); - SendDioEnableState(); + if (adcMask.HasChannels) + { + Send(ScpiMessageProducer.EnableAdcChannels(adcMask.Mask.ToString(CultureInfo.InvariantCulture))); + } + + if (dioState.HasChannels) + { + Send(dioState.AnyEnabled + ? ScpiMessageProducer.EnableDioPorts() + : ScpiMessageProducer.DisableDioPorts()); + } } /// @@ -1200,38 +1220,60 @@ private void SetChannelsEnabled(IReadOnlyList channels, bool enabled) var touchedAnalog = false; var touchedDigital = false; + var adcMask = (HasChannels: false, Mask: 0u); + var dioState = (HasChannels: false, AnyEnabled: false); - foreach (var channel in channels) + // Mutate IsEnabled and derive the outbound masks from it in one critical section, so + // a status frame resyncing analog IsEnabled from the device (#409) on the consumer + // thread cannot interleave between the mutation and the read that computes the mask — + // which would otherwise send a mask reflecting a value the status frame is about to + // overwrite, silently failing to apply the requested enable/disable on the device. + WithChannelsLock(() => { - channel.IsEnabled = enabled; + foreach (var channel in channels) + { + channel.IsEnabled = enabled; + + if (channel.Type == ChannelType.Analog) + { + touchedAnalog = true; + } + else if (channel.Type == ChannelType.Digital) + { + touchedDigital = true; + } + } - if (channel.Type == ChannelType.Analog) + if (touchedAnalog) { - touchedAnalog = true; + adcMask = ComputeAdcEnableMask(); } - else if (channel.Type == ChannelType.Digital) + + if (touchedDigital) { - touchedDigital = true; + dioState = ComputeDioEnableState(); } - } + }); - if (touchedAnalog) + if (touchedAnalog && adcMask.HasChannels) { - SendAdcEnableMask(); + Send(ScpiMessageProducer.EnableAdcChannels(adcMask.Mask.ToString(CultureInfo.InvariantCulture))); } - if (touchedDigital) + if (touchedDigital && dioState.HasChannels) { - SendDioEnableState(); + Send(dioState.AnyEnabled + ? ScpiMessageProducer.EnableDioPorts() + : ScpiMessageProducer.DisableDioPorts()); } } /// - /// Recomputes the ADC enable bitmask over all currently-enabled analog channels and sends it. - /// Does nothing when the device has no analog channels. The firmware treats the value as a - /// set-replace, so the full mask of enabled analog channels is sent every time. + /// Computes the ADC enable bitmask over all currently-enabled analog channels. Must be + /// called under alongside any IsEnabled + /// mutation it should reflect (#409) — see . /// - private void SendAdcEnableMask() + private (bool HasChannels, uint Mask) ComputeAdcEnableMask() { uint mask = 0; var hasAnalogChannels = false; @@ -1259,6 +1301,18 @@ private void SendAdcEnableMask() mask |= 1u << channel.ChannelNumber; } + return (hasAnalogChannels, mask); + } + + /// + /// Recomputes the ADC enable bitmask over all currently-enabled analog channels and sends it. + /// Does nothing when the device has no analog channels. The firmware treats the value as a + /// set-replace, so the full mask of enabled analog channels is sent every time. + /// + private void SendAdcEnableMask() + { + var (hasAnalogChannels, mask) = WithChannelsLock(ComputeAdcEnableMask); + if (!hasAnalogChannels) { return; @@ -1268,11 +1322,11 @@ private void SendAdcEnableMask() } /// - /// Sends the global DIO enable command reflecting whether any digital channel is enabled. - /// Does nothing when the device has no digital channels. The firmware exposes only a global - /// DIO enable, so per-channel digital enabling is collapsed to this aggregate state. + /// Computes whether any digital channel is enabled. Must be called under + /// alongside any IsEnabled mutation it + /// should reflect — see . /// - private void SendDioEnableState() + private (bool HasChannels, bool AnyEnabled) ComputeDioEnableState() { var hasDigitalChannels = false; var anyEnabled = false; @@ -1292,6 +1346,18 @@ private void SendDioEnableState() } } + return (hasDigitalChannels, anyEnabled); + } + + /// + /// Sends the global DIO enable command reflecting whether any digital channel is enabled. + /// Does nothing when the device has no digital channels. The firmware exposes only a global + /// DIO enable, so per-channel digital enabling is collapsed to this aggregate state. + /// + private void SendDioEnableState() + { + var (hasDigitalChannels, anyEnabled) = WithChannelsLock(ComputeDioEnableState); + if (!hasDigitalChannels) { return; From a2dd5adbb7674d7343ea7b7fdf23a73217098161 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Thu, 30 Jul 2026 11:24:29 -0600 Subject: [PATCH 3/4] test(device): stop the concurrency regression test from busy-spinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...DaqifiStreamingDeviceChannelManagementTests.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs index 3556f930..33129033 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs @@ -262,12 +262,25 @@ public async Task EnableChannel_ConcurrentWithStatusResync_AlwaysSendsMaskInclud AnalogInPortEnabled = Google.Protobuf.ByteString.CopyFrom(0b0000_0000) }; - using var stop = new CancellationTokenSource(); + // Safety net so a regression that reintroduces a deadlock fails the test instead of + // hanging CI indefinitely. + using var stop = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var resyncTask = Task.Run(() => { + var iteration = 0; while (!stop.IsCancellationRequested) { device.PopulateChannelsFromStatus(allDisabledStatus); + // Yield periodically rather than every iteration — yielding every iteration + // (e.g. via SpinWait.SpinOnce) spaces status frames out enough that this loop + // stops reliably landing inside EnableChannel's now-much-shorter critical + // section, silently defeating the regression coverage. Yielding every 64th + // iteration keeps the interleaving pressure while still relinquishing the core + // regularly enough to avoid pegging it. + if (++iteration % 64 == 0) + { + Thread.Yield(); + } } }); From 3a0eaf63b23bcf9b1177c701921f70c7fbe09ff2 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Thu, 30 Jul 2026 11:36:40 -0600 Subject: [PATCH 4/4] test(device): bound the resync worker wait so a real deadlock fails the 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. --- ...DaqifiStreamingDeviceChannelManagementTests.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs index 33129033..9623a9f2 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs @@ -300,7 +300,20 @@ public async Task EnableChannel_ConcurrentWithStatusResync_AlwaysSendsMaskInclud finally { stop.Cancel(); - await resyncTask; + + // The cancellation token only stops the loop between iterations — it can't + // interrupt a PopulateChannelsFromStatus call already in progress. If a + // reintroduced deadlock ever wedges that call, awaiting resyncTask unbounded would + // hang CI instead of failing the test. Bound the wait so a deadlock regression + // fails deterministically instead. + try + { + await resyncTask.WaitAsync(TimeSpan.FromSeconds(5)); + } + catch (TimeoutException) + { + Assert.Fail("The status-resync worker did not stop within 5s of cancellation — possible deadlock regression."); + } } }