diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceDrainErrorQueueTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceDrainErrorQueueTests.cs index fb3ab072..847fea4f 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceDrainErrorQueueTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceDrainErrorQueueTests.cs @@ -203,17 +203,25 @@ public override void Send(IOutboundMessage message) } } - protected override Task> ExecuteTextCommandAsync( + protected override async Task> ExecuteTextCommandAsync( Action setupAction, int responseTimeoutMs = 1000, int completionTimeoutMs = 250, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? prepareAsync = null) { + // Honor the exchange's prepare phase the way the real device does: it runs first, + // before anything this exchange sends (#396). + if (prepareAsync != null) + { + await prepareAsync(cancellationToken).ConfigureAwait(false); + } + cancellationToken.ThrowIfCancellationRequested(); setupAction(); ExecuteTextCommandCallCount++; var reply = Replies.Count > 0 ? Replies.Dequeue() : Array.Empty(); - return Task.FromResult(reply); + return reply; } } } diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceInitializeTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceInitializeTests.cs index f56f319e..a29a0deb 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceInitializeTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceInitializeTests.cs @@ -428,23 +428,30 @@ public override void Send(IOutboundMessage message) } } - protected override Task> ExecuteTextCommandAsync( + protected override async Task> ExecuteTextCommandAsync( Action setupAction, int responseTimeoutMs = 1000, int completionTimeoutMs = 250, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? prepareAsync = null) { + // Honor the exchange's prepare phase the way the real device does: it runs first, + // before anything this exchange sends (#396). + if (prepareAsync != null) + { + await prepareAsync(cancellationToken).ConfigureAwait(false); + } + // Run the setup action so that Send() calls inside it are captured setupAction(); TextCommandAttemptCount++; if (_failFirstAttempt && TextCommandAttemptCount == 1) { - return Task.FromResult>( - new[] { "**ERROR: -200, \"Execution error\"\r\n" }); + return new[] { "**ERROR: -200, \"Execution error\"\r\n" }; } - return Task.FromResult(_textCommandResponse); + return _textCommandResponse; } } @@ -501,12 +508,20 @@ public override void Send(IOutboundMessage message) } } - protected override Task> ExecuteTextCommandAsync( + protected override async Task> ExecuteTextCommandAsync( Action setupAction, int responseTimeoutMs = 1000, int completionTimeoutMs = 250, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? prepareAsync = null) { + // Honor the exchange's prepare phase the way the real device does: it runs first, + // before anything this exchange sends (#396). + if (prepareAsync != null) + { + await prepareAsync(cancellationToken).ConfigureAwait(false); + } + var before = _sent.Count; setupAction(); var sentThisCall = _sent.Skip(before).ToList(); @@ -519,13 +534,11 @@ protected override Task> ExecuteTextCommandAsync( switch (_usbStepBehavior) { case UsbStepBehavior.ScpiError: - return Task.FromResult>( - new[] { "**ERROR: -200, \"Execution error\"\r\n" }); + return new[] { "**ERROR: -200, \"Execution error\"\r\n" }; case UsbStepBehavior.ScpiErrorThenSucceed: if (UsbStepAttemptCount == 1) { - return Task.FromResult>( - new[] { "**ERROR: -200, \"Execution error\"\r\n" }); + return new[] { "**ERROR: -200, \"Execution error\"\r\n" }; } break; case UsbStepBehavior.Cancel: @@ -533,7 +546,7 @@ protected override Task> ExecuteTextCommandAsync( } } - return Task.FromResult>(Array.Empty()); + return Array.Empty(); } } } diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceStaleTextLineTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceStaleTextLineTests.cs index 895fccc4..010a98db 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceStaleTextLineTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceStaleTextLineTests.cs @@ -55,7 +55,106 @@ public async Task ExecuteTextCommand_KeepsLinesThatArriveAfterTheExchangeSentSom device.Disconnect(); } - /// Exposes the protected text-exchange entry point. + [Fact] + public async Task ExecuteTextCommandWithPrepare_RunsPrepareBeforeTheSetupAction() + { + using var transport = new ReleaseOnStreamAccessMockTransport("0,\"No error\"\r\n"); + using var device = new StaleLineTestableDevice("Prepared Device", transport); + + device.Connect(); + + var order = new List(); + await device.CallWithPrepareAsync( + _ => { order.Add("prepare"); return Task.CompletedTask; }, + () => order.Add("setup")); + + Assert.Equal(new[] { "prepare", "setup" }, order); + + device.Disconnect(); + } + + [Fact] + public async Task ExecuteTextCommandWithPrepare_RunsPrepareInsideTheExchange() + { + // The property that matters for the SD card operations: the prepare phase holds the + // device-wide text-exchange lock, so no competing exchange can interleave between the SPI + // bus switch it performs and the commands that depend on it. Asserted through the + // exchange's own re-entrancy guard rather than by racing two threads — if prepare runs + // inside the critical section, a nested exchange must be refused, and if it had been + // hoisted back outside the lock this would silently succeed instead. + using var transport = new ReleaseOnStreamAccessMockTransport("0,\"No error\"\r\n"); + using var device = new StaleLineTestableDevice("Nested Device", transport); + + device.Connect(); + + var ex = await Assert.ThrowsAsync( + () => device.CallWithPrepareAsync( + async _ => await device.CallExecuteTextCommandAsync(() => { }), + () => { })); + + Assert.Contains("not re-entrant", ex.Message); + + device.Disconnect(); + } + + [Fact] + public async Task ExecuteTextCommand_CarryingAPreparePhase_IsStillCaughtByASubclassOverride() + { + // The prepare phase is a parameter on the existing virtual rather than a second virtual + // method, so a subclass that overrides ExecuteTextCommandAsync keeps intercepting the SD + // operations that use it. A parallel seam would route past such an override with no compile + // error and no runtime signal — an instrumented device or test double would simply stop + // seeing SD traffic. If this ever regresses to a sibling method, this test fails. + using var transport = new ReleaseOnStreamAccessMockTransport("0,\"No error\"\r\n"); + using var device = new InterceptingTestableDevice("Intercepting Device", transport); + + device.Connect(); + + var prepared = false; + var lines = await device.CallWithPrepareAsync( + _ => { prepared = true; return Task.CompletedTask; }, + () => { }); + + Assert.True(device.Intercepted, "The subclass override did not see the call."); + Assert.True(prepared, "The override was handed the prepare phase and ran it."); + Assert.Equal(new[] { "from the override" }, lines); + + device.Disconnect(); + } + + /// + /// Stands in for a downstream subclass or test double that intercepts the text exchange — + /// the case the single-seam design protects. + /// + private sealed class InterceptingTestableDevice : StaleLineTestableDevice + { + public InterceptingTestableDevice(string name, IStreamTransport transport) + : base(name, transport) + { + } + + public bool Intercepted { get; private set; } + + protected override async Task> ExecuteTextCommandAsync( + Action setupAction, + int responseTimeoutMs = 1000, + int completionTimeoutMs = 250, + CancellationToken cancellationToken = default, + Func? prepareAsync = null) + { + Intercepted = true; + + if (prepareAsync != null) + { + await prepareAsync(cancellationToken).ConfigureAwait(false); + } + + setupAction(); + return new List { "from the override" }; + } + } + + /// Exposes the protected text-exchange entry points. private class StaleLineTestableDevice : DaqifiDevice { public StaleLineTestableDevice(string name, IStreamTransport transport) @@ -67,6 +166,17 @@ public Task> CallExecuteTextCommandAsync(Action setupActio { return ExecuteTextCommandAsync(setupAction, responseTimeoutMs: 500, completionTimeoutMs: 150); } + + public Task> CallWithPrepareAsync( + Func prepareAsync, + Action setupAction) + { + return ExecuteTextCommandAsync( + setupAction, + responseTimeoutMs: 500, + completionTimeoutMs: 150, + prepareAsync: prepareAsync); + } } /// diff --git a/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs b/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs index 714a3322..83533900 100644 --- a/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs +++ b/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs @@ -302,15 +302,23 @@ public override void Send(IOutboundMessage message) } } - protected override Task> ExecuteTextCommandAsync( + protected override async Task> ExecuteTextCommandAsync( Action setupAction, int responseTimeoutMs = 1000, int completionTimeoutMs = 250, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? prepareAsync = null) { + // Honor the exchange's prepare phase the way the real device does: it runs first, + // before anything this exchange sends (#396). + if (prepareAsync != null) + { + await prepareAsync(cancellationToken).ConfigureAwait(false); + } + cancellationToken.ThrowIfCancellationRequested(); setupAction(); - return Task.FromResult>(CannedTextResponse.ToList()); + return CannedTextResponse.ToList(); } protected override async Task> ExecuteTextCommandAsync( diff --git a/src/Daqifi.Core.Tests/Device/GetLanChipInfoAsyncTests.cs b/src/Daqifi.Core.Tests/Device/GetLanChipInfoAsyncTests.cs index 6d449e09..7fc05269 100644 --- a/src/Daqifi.Core.Tests/Device/GetLanChipInfoAsyncTests.cs +++ b/src/Daqifi.Core.Tests/Device/GetLanChipInfoAsyncTests.cs @@ -90,15 +90,23 @@ public override void Send(IOutboundMessage message) } } - protected override Task> ExecuteTextCommandAsync( + protected override async Task> ExecuteTextCommandAsync( Action setupAction, int responseTimeoutMs = 1000, int completionTimeoutMs = 250, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? prepareAsync = null) { + // Honor the exchange's prepare phase the way the real device does: it runs first, + // before anything this exchange sends (#396). + if (prepareAsync != null) + { + await prepareAsync(cancellationToken).ConfigureAwait(false); + } + cancellationToken.ThrowIfCancellationRequested(); setupAction(); - return Task.FromResult>(CannedTextResponse.ToList()); + return CannedTextResponse.ToList(); } protected override async Task> ExecuteTextCommandAsync( diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs index 17fab924..c307cef0 100644 --- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs @@ -1966,20 +1966,28 @@ public override void Send(IOutboundMessage message) } } - protected override Task> ExecuteTextCommandAsync( + protected override async Task> ExecuteTextCommandAsync( Action setupAction, int responseTimeoutMs = 1000, int completionTimeoutMs = 250, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? prepareAsync = null) { + // Honor the exchange's prepare phase the way the real device does: it runs first, + // before anything this exchange sends (#396). + if (prepareAsync != null) + { + await prepareAsync(cancellationToken).ConfigureAwait(false); + } + var sentBefore = SentMessages.Count; setupAction(); ExecuteTextCommandCallCount++; var response = ResponseSequence.Count > 0 ? ResponseSequence.Dequeue() : new List(); - return Task.FromResult(SdCardTestResponses.AnswerErrorQuery( - response, SentMessages, sentBefore, ExecuteTextCommandCallCount, UnterminatedAttempts)); + return SdCardTestResponses.AnswerErrorQuery( + response, SentMessages, sentBefore, ExecuteTextCommandCallCount, UnterminatedAttempts); } protected override async Task> ExecuteTextCommandAsync( @@ -2086,19 +2094,27 @@ public override void Send(IOutboundMessage message) } } - protected override Task> ExecuteTextCommandAsync( + protected override async Task> ExecuteTextCommandAsync( Action setupAction, int responseTimeoutMs = 1000, int completionTimeoutMs = 250, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? prepareAsync = null) { + // Honor the exchange's prepare phase the way the real device does: it runs first, + // before anything this exchange sends (#396). + if (prepareAsync != null) + { + await prepareAsync(cancellationToken).ConfigureAwait(false); + } + var sentBefore = SentMessages.Count; // Execute the setup action so we can capture the SCPI commands setupAction(); _executeTextCommandCallCount++; - return Task.FromResult(SdCardTestResponses.AnswerErrorQuery( - CannedTextResponse, SentMessages, sentBefore, _executeTextCommandCallCount, UnterminatedAttempts)); + return SdCardTestResponses.AnswerErrorQuery( + CannedTextResponse, SentMessages, sentBefore, _executeTextCommandCallCount, UnterminatedAttempts); } protected override async Task> ExecuteTextCommandAsync( @@ -2142,14 +2158,22 @@ public override void Send(IOutboundMessage message) } } - protected override Task> ExecuteTextCommandAsync( + protected override async Task> ExecuteTextCommandAsync( Action setupAction, int responseTimeoutMs = 1000, int completionTimeoutMs = 250, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? prepareAsync = null) { + // Honor the exchange's prepare phase the way the real device does: it runs first, + // before anything this exchange sends (#396). + if (prepareAsync != null) + { + await prepareAsync(cancellationToken).ConfigureAwait(false); + } + setupAction(); - return Task.FromResult>(CannedTextResponse); + return CannedTextResponse; } protected override async Task> ExecuteTextCommandAsync( @@ -2281,14 +2305,22 @@ public override void Send(IOutboundMessage message) } } - protected override Task> ExecuteTextCommandAsync( + protected override async Task> ExecuteTextCommandAsync( Action setupAction, int responseTimeoutMs = 1000, int completionTimeoutMs = 250, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? prepareAsync = null) { + // Honor the exchange's prepare phase the way the real device does: it runs first, + // before anything this exchange sends (#396). + if (prepareAsync != null) + { + await prepareAsync(cancellationToken).ConfigureAwait(false); + } + setupAction(); - return Task.FromResult>(new List()); + return new List(); } protected override async Task> ExecuteTextCommandAsync( @@ -2356,16 +2388,24 @@ public override void Send(IOutboundMessage message) } } - protected override Task> ExecuteTextCommandAsync( + protected override async Task> ExecuteTextCommandAsync( Action setupAction, int responseTimeoutMs = 1000, int completionTimeoutMs = 250, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? prepareAsync = null) { + // Honor the exchange's prepare phase the way the real device does: it runs first, + // before anything this exchange sends (#396). + if (prepareAsync != null) + { + await prepareAsync(cancellationToken).ConfigureAwait(false); + } + var sentBefore = SentMessages.Count; setupAction(); - return Task.FromResult(SdCardTestResponses.AnswerErrorQuery( - CannedTextResponse, SentMessages, sentBefore, attemptNumber: 1, unterminatedAttempts: 0)); + return SdCardTestResponses.AnswerErrorQuery( + CannedTextResponse, SentMessages, sentBefore, attemptNumber: 1, unterminatedAttempts: 0); } protected override async Task> ExecuteTextCommandAsync( diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs index 13be9768..164d2121 100644 --- a/src/Daqifi.Core/Device/DaqifiDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiDevice.cs @@ -509,7 +509,7 @@ public void Connect() /// /// Waits up to 10 seconds to acquire _textExchangeLock before /// tearing down the consumer / producer / transport. This prevents - /// a race where an in-flight + /// a race where an in-flight /// is mid-swap (text consumer running on the stream, protobuf /// consumer not yet restarted) and Disconnect rips the transport /// out from under it. If the wait times out, Disconnect proceeds @@ -738,6 +738,20 @@ private void RestartMessageConsumerAfterSwap() /// The time in milliseconds to wait for the first text response after sending commands. /// The time in milliseconds of inactivity after the first response before considering the response complete. Defaults to 250ms. /// A cancellation token to observe while waiting for the task to complete. + /// + /// Optional phase that puts the device into the state the commands require, for callers that + /// need one — the SD card operations use it to switch the shared SPI bus over to the card and + /// wait for the firmware to settle. + /// + /// It runs inside the device-wide text-exchange lock, so no competing exchange can interleave + /// between it and and undo what it established. It also runs + /// before the consumer swap, and therefore before the stale-line boundary below: a settle + /// wait placed inside would widen that boundary into a window + /// where a late reply to an earlier command could be captured as part of this response + /// (#396). Anything the device emits in reply to it goes to the protobuf consumer, exactly as + /// it did before this exchange began. + /// + /// /// /// A list of text lines received from the device. Lines that were already in flight when the /// exchange opened — late replies to earlier commands — are excluded: only what arrived once @@ -745,21 +759,33 @@ private void RestartMessageConsumerAfterSwap() /// /// Thrown when the device is not connected or has no transport. /// Thrown when the operation is canceled. + // prepareAsync is added AFTER cancellationToken (technically violating CA1068 + // "CancellationToken should be last") to keep existing positional callers working, matching + // the convention established in IFirmwareUpdateService for the same reason. It is a + // parameter on this seam rather than a second virtual method deliberately: a parallel + // method would be bypassed silently by any subclass that overrides only this one, which for + // an instrumented device or a test double means the override quietly stops intercepting SD + // operations with nothing to indicate it. Overriders must widen their signature — a compile + // error, which is the point. +#pragma warning disable CA1068 protected virtual Task> ExecuteTextCommandAsync( Action setupAction, int responseTimeoutMs = 1000, int completionTimeoutMs = 250, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + Func? prepareAsync = null) { return ExecuteTextCommandCoreAsync( + prepareAsync, _ => { setupAction(); return Task.CompletedTask; }, responseTimeoutMs, completionTimeoutMs, cancellationToken); } +#pragma warning restore CA1068 /// - /// Async overload of + /// Async overload of /// that accepts an async setup action so callers can await cancellable operations /// (e.g. ) between SCPI commands without /// blocking the thread-pool thread. @@ -778,6 +804,7 @@ protected virtual Task> ExecuteTextCommandAsync( CancellationToken cancellationToken = default) { return ExecuteTextCommandCoreAsync( + prepareAsync: null, setupActionAsync, responseTimeoutMs, completionTimeoutMs, @@ -785,6 +812,7 @@ protected virtual Task> ExecuteTextCommandAsync( } private async Task> ExecuteTextCommandCoreAsync( + Func? prepareAsync, Func setupActionAsync, int responseTimeoutMs, int completionTimeoutMs, @@ -863,6 +891,19 @@ private async Task> ExecuteTextCommandCoreAsync( } var sw = Stopwatch.StartNew(); + + // Prepare phase, if any. Deliberately here: inside the lock, so no competing text + // exchange can interleave between it and the setup action below and undo the state + // it establishes; and before the consumer swap, so the wait it typically needs + // cannot widen the stale-line boundary taken further down. Any device output it + // provokes goes to the protobuf consumer, which is still running at this point. + if (prepareAsync != null) + { + await prepareAsync(cancellationToken).ConfigureAwait(false); + + SafeLog(() => _logger.LogDebug("[ExecuteTextCommandAsync] Prepare phase completed at {ElapsedMs}ms", sw.ElapsedMilliseconds)); + } + var collectedLines = new List(); var stream = _transport.Stream; int? originalReadTimeout = null; @@ -1053,7 +1094,7 @@ private async Task> ExecuteTextCommandCoreAsync( /// on hardware faults, or discard them if known-stale. /// /// - /// Each iteration uses , which + /// Each iteration uses , which /// pauses the protobuf consumer for the duration of the text exchange. /// Avoid calling this during active streaming or concurrently with /// other text commands. diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 625386fa..b7312e2a 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -1661,19 +1661,13 @@ public async Task> GetSdCardFilesAsync(Cancellatio await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken).ConfigureAwait(false); } - // Switch the shared SPI bus over to the SD card and let the firmware settle - // BEFORE opening the text exchange. Querying the card too soon after the switch - // makes the device answer -200 (Execution error), so the delay itself is not - // optional — but running it outside the exchange leaves the exchange with no - // internal gap at all, so its very first act is the LIST query. That matters for - // the terminator: the exchange discards anything received before its setup - // action sends, and a gap inside the action would widen that boundary into a - // window where a late reply to an earlier command could still be mistaken for - // this listing's terminator. The delay is unchanged from the device's point of - // view — if anything longer, since the consumer swap now follows it. - PrepareSdInterface(); - await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken).ConfigureAwait(false); - + // The SPI bus switch and its settle wait run as the exchange's prepare phase: + // inside the exchange lock, so a competing text exchange cannot restore the LAN + // interface between the switch and the LIST, and ahead of the stale-line + // boundary, so the settle wait does not become a window in which a late reply to + // an earlier command could pass for this listing's terminator. Querying the card + // too soon after the switch makes the device answer -200 (Execution error), so + // the wait itself is not optional. lines = await ExecuteTextCommandAsync( () => { @@ -1685,7 +1679,8 @@ public async Task> GetSdCardFilesAsync(Cancellatio }, responseTimeoutMs: 3000, completionTimeoutMs: SD_LIST_COMPLETION_TIMEOUT_MS, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken, + prepareAsync: PrepareSdInterfaceAndSettleAsync); isComplete = TrySplitAtSdListTerminator(lines, out listing); @@ -1716,6 +1711,28 @@ public async Task> GetSdCardFilesAsync(Cancellatio return files; } + /// + /// Prepare phase shared by the SD card text exchanges: switches the shared SPI bus over to + /// the card and waits for the firmware to complete the switch. + /// + /// + /// Passed as the prepareAsync phase of + /// + /// rather than run + /// inline, so it executes inside the text-exchange lock — a competing exchange restoring the + /// LAN interface between the switch and the commands that depend on it would leave them + /// running against the wrong interface — and ahead of the exchange's stale-line boundary, so + /// the settle wait cannot be mistaken for a window in which the device was answering. + /// + private async Task PrepareSdInterfaceAndSettleAsync(CancellationToken cancellationToken) + { + PrepareSdInterface(); + + // Querying the card too soon after the switch makes the device answer -200 + // (Execution error), so this wait is not optional. + await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken).ConfigureAwait(false); + } + /// /// Splits a raw SD listing response at the SYSTem:ERRor? terminator reply that /// appends to the exchange. @@ -2122,15 +2139,11 @@ public async Task DeleteSdCardFileAsync(string fileName, CancellationToken cance IReadOnlyList lines; try { - // Switch the shared SPI bus to the SD card and settle BEFORE opening the text - // exchange, for the same reason as GetSdCardFilesAsync: a gap inside the setup - // action is a window in which a late reply to an earlier command can be captured - // as part of this response. Here that would mean a stale error line triggering a - // pointless delete-and-relist retry rather than a bad listing, but it is the same - // defect, so it gets the same treatment. - PrepareSdInterface(); - await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken).ConfigureAwait(false); - + // Same prepare-phase treatment as GetSdCardFilesAsync, for the same two reasons — + // the SPI switch stays serialized against competing text exchanges, and its settle + // wait stays outside the stale-line boundary. The consequence of a stale line is + // milder here (delete keys off ContainsScpiError, so it would mean a pointless + // delete-and-relist retry rather than a bad listing) but it is the same defect. lines = await ExecuteTextCommandAsync( () => { @@ -2138,7 +2151,8 @@ public async Task DeleteSdCardFileAsync(string fileName, CancellationToken cance Send(ScpiMessageProducer.GetSdFileList); }, responseTimeoutMs: 3000, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken, + prepareAsync: PrepareSdInterfaceAndSettleAsync); if (ContainsScpiError(lines)) { @@ -2148,9 +2162,6 @@ public async Task DeleteSdCardFileAsync(string fileName, CancellationToken cance await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken).ConfigureAwait(false); - PrepareSdInterface(); - await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken).ConfigureAwait(false); - lines = await ExecuteTextCommandAsync( () => { @@ -2158,7 +2169,8 @@ public async Task DeleteSdCardFileAsync(string fileName, CancellationToken cance Send(ScpiMessageProducer.GetSdFileList); }, responseTimeoutMs: 3000, - cancellationToken: cancellationToken); + cancellationToken: cancellationToken, + prepareAsync: PrepareSdInterfaceAndSettleAsync); if (!ContainsScpiError(lines)) {