Skip to content

fix(device): serialize ExecuteTextCommandAsync (closes #186)#196

Open
cptkoolbeenz wants to merge 5 commits into
mainfrom
fix/execute-text-command-lock
Open

fix(device): serialize ExecuteTextCommandAsync (closes #186)#196
cptkoolbeenz wants to merge 5 commits into
mainfrom
fix/execute-text-command-lock

Conversation

@cptkoolbeenz
Copy link
Copy Markdown
Member

Summary

Adds SemaphoreSlim around ExecuteTextCommandAsync so concurrent callers no longer race the protobuf-consumer pause/swap/restart sequence on the same stream. Also closes the TOCTOU window between the existing pre-lock validation and consumer manipulation, detects same-thread re-entrant calls explicitly, and rejects calls during disposed/disconnecting state.

Implementation

  • _textExchangeLock = new SemaphoreSlim(1, 1) mutex. Disposed in Dispose().
  • _textExchangeOwnerThreadId tracks the holding thread. Same-thread re-entry (a setupAction recursing into ExecuteTextCommandAsync) is detected BEFORE WaitAsync() and throws InvalidOperationException("not re-entrant") instead of deadlocking.
  • ALL validation moved INSIDE the lock — competing Disconnect() / Dispose() during WaitAsync() no longer leaves a stale _transport / _messageConsumer reference (TOCTOU close).
  • New _disposed || _isDisconnecting guard rejects calls during teardown.

Mirrors daqifi/daqifi-python-core PR #104.

Test plan

  • 5 new tests in DaqifiDeviceTextCommandLockTests (re-entrancy guard, _isDisconnecting, _disposed, lock released after validation failure, owner cleared after exception). Reflection-based — guards run before transport interaction so this is faithful coverage without a transport stack.
  • Existing tests still pass.
  • 895 tests pass on net9.0 + net10.0 (was 890).

Refs

Adds a SemaphoreSlim around ExecuteTextCommandAsync so concurrent
callers (e.g. simultaneous GetSdCardFilesAsync + DrainErrorQueueAsync
+ GetSystemInfoAsync) no longer race the protobuf-consumer
pause/swap/restart sequence on the same stream.

Without the lock, two callers could:
- Spawn separate text consumers on the SAME underlying stream — each
  gets a random subset of the device's reply lines.
- Interleave SCPI bytes on the wire — firmware sees intermixed bytes
  and may reply with parse errors.
- Race the protobuf-consumer restart in the outer finally block.
- Stop a consumer mid-restart (one caller's StopSafely targets the
  consumer the other caller just restarted).

Implementation:
- New `_textExchangeLock = new SemaphoreSlim(1, 1)` for mutual
  exclusion. Disposed in Dispose().
- New `_textExchangeOwnerThreadId` tracks the holding thread. A
  same-thread re-entrant call (a setupAction that itself calls
  ExecuteTextCommandAsync) would corrupt the consumer swap; we
  detect it BEFORE WaitAsync() and throw InvalidOperationException
  ("not re-entrant"). Strictly safer than the silent deadlock
  WaitAsync would otherwise produce.
- ALL validation moved INSIDE the lock so a competing thread
  calling DisconnectAsync() / Dispose() during our WaitAsync()
  doesn't leave us with a stale _transport / _messageConsumer
  reference (TOCTOU close).
- New disposed/disconnecting guard at the top of the locked body
  rejects calls during teardown — even before the existing
  IsConnected / transport-null checks.
- Owner cleared and semaphore Released in the outer finally so
  validation failures can't leak the lock (verified in tests).

5 new tests in DaqifiDeviceTextCommandLockTests:
- Same-thread owner → InvalidOperationException("not re-entrant")
- _isDisconnecting=true → throws "disposing or disconnecting"
- _disposed=true → throws "disposing or disconnecting"
- Lock released after validation failure (second call doesn't deadlock)
- Owner thread ID cleared after method returns (even on exception)

Tests use reflection to set the relevant private fields — those guards
run before any transport/consumer interaction, so this gives faithful
coverage without standing up a real transport stack.

Mirrors the equivalent fix already merged in daqifi-python-core PR #104
(execute_text_command + _text_exchange_lock + RLock + disposed/disconnecting
guard + post-acquisition validation).

895 tests pass on net9.0 + net10.0 (was 890 baseline).
@cptkoolbeenz cptkoolbeenz requested a review from a team as a code owner May 12, 2026 01:47
@qodo-code-review
Copy link
Copy Markdown

Review Summary by Qodo

Serialize ExecuteTextCommandAsync with SemaphoreSlim to prevent race conditions

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Adds SemaphoreSlim lock to serialize concurrent ExecuteTextCommandAsync calls
• Detects same-thread re-entrant calls and throws InvalidOperationException
• Moves all validation inside lock to close TOCTOU window
• Rejects calls during disposed/disconnecting state
• Adds 5 comprehensive tests covering lock behavior and edge cases
Diagram
flowchart LR
  A["Concurrent callers"] -->|"acquire lock"| B["SemaphoreSlim"]
  B -->|"check re-entrancy"| C["Same thread?"]
  C -->|"yes"| D["Throw InvalidOperationException"]
  C -->|"no"| E["Check disposed/disconnecting"]
  E -->|"yes"| D
  E -->|"no"| F["Execute text command"]
  F -->|"finally"| G["Release lock & clear owner"]
Loading

Grey Divider

File Changes

1. src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs 🧪 Tests +145/-0

Add comprehensive tests for ExecuteTextCommandAsync lock

• New test class with 5 tests covering lock serialization and re-entrancy detection
• Tests verify same-thread re-entrancy guard throws InvalidOperationException
• Tests verify _isDisconnecting and _disposed guards reject calls
• Tests verify lock is released after validation failures
• Tests verify owner thread ID is cleared after method returns
• Uses reflection to set private fields for testing guard logic without transport

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


2. src/Daqifi.Core/Device/DaqifiDevice.cs 🐞 Bug fix +66/-13

Serialize ExecuteTextCommandAsync with lock and guards

• Adds _textExchangeLock SemaphoreSlim field for mutual exclusion
• Adds _textExchangeOwnerThreadId field to track lock-holding thread
• Moves all validation logic inside lock to prevent TOCTOU race conditions
• Adds re-entrancy check before WaitAsync() to detect same-thread calls
• Adds disposed/disconnecting guard inside locked section
• Wraps method body in try-finally to ensure lock release and owner cleanup
• Disposes _textExchangeLock in Dispose() method

src/Daqifi.Core/Device/DaqifiDevice.cs


Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 12, 2026

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)

Context used

Grey Divider


Action required

1. Disconnect wait too short ✓ Resolved 🐞 Bug ☼ Reliability
Description
Disconnect() only waits 1 second for _textExchangeLock, but ExecuteTextCommandAsync can hold the
lock for several seconds (protobuf StopSafely up to 1s + response loop up to responseTimeoutMs*5,
default 5s). This often causes Disconnect/Dispose to proceed without synchronization and tear down
the transport/consumers while a text exchange is still running, reintroducing the race the semaphore
is meant to prevent.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R222-247]

+        /// Waits up to 1 second to acquire <c>_textExchangeLock</c> before
+        /// tearing down the consumer / producer / transport. This prevents
+        /// a race where an in-flight <see cref="ExecuteTextCommandAsync"/>
+        /// 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
+        /// anyway — a stuck text exchange must not block teardown forever.
+        /// The 1s budget is the longest delay any normal text exchange
+        /// can hold the lock for (responseTimeoutMs default + safety
+        /// margin); callers wanting non-blocking disconnect should drive
+        /// this off a Task.Run.
+        /// </remarks>
        public void Disconnect()
        {
            _isDisconnecting = true;
+            // Best-effort coordination with ExecuteTextCommandAsync —
+            // acquire the lock so we don't tear the transport out from
+            // under an in-flight text exchange. The lock IS released in
+            // the finally below when acquired (so a future Connect()
+            // followed by ExecuteTextCommandAsync isn't blocked); a
+            // stuck exchange that holds past the timeout drops to the
+            // _isDisconnecting validation path inside the exchange.
+            var lockAcquired = false;
+            try
+            {
+                lockAcquired = _textExchangeLock.Wait(TimeSpan.FromSeconds(1));
Evidence
Disconnect’s 1s wait is shorter than the time the text-exchange path can hold the semaphore (default
timeouts), so Disconnect/Dispose can proceed without the lock and tear down resources mid-exchange.

src/Daqifi.Core/Device/DaqifiDevice.cs[218-248]
src/Daqifi.Core/Device/DaqifiDevice.cs[380-385]
src/Daqifi.Core/Device/DaqifiDevice.cs[467-478]
src/Daqifi.Core/Device/DaqifiDevice.cs[506-520]
src/Daqifi.Core/Device/DaqifiDevice.cs[717-726]

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

### Issue description
`Disconnect()` only waits 1 second to acquire `_textExchangeLock`, but `ExecuteTextCommandAsync` can hold the lock for significantly longer under normal timeout paths. This means `Disconnect()`/`Dispose()` can still tear down the transport mid-text-exchange.

### Issue Context
The new code intends to coordinate teardown with in-flight text exchanges. However, the 1s wait is shorter than the exchange’s own worst-case duration (default timeouts).

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[222-232]
- src/Daqifi.Core/Device/DaqifiDevice.cs[237-248]

### Suggested fix
- Increase the `Wait(...)` budget to cover the actual worst-case lock hold time for a default text exchange (e.g., >= `StopSafely(1000ms)` + `responseTimeoutMs*5` + a small margin), or make the timeout configurable/derived from shared constants.
- Update the remarks to match the real maximum hold time (or rephrase as “best-effort wait”).

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


2. Semaphore disposed while in use ✓ Resolved 🐞 Bug ☼ Reliability
Description
Dispose() disposes _textExchangeLock even if an ExecuteTextCommandAsync is in-flight, so the
in-flight call can throw ObjectDisposedException from its finally on Release(), masking the
original outcome and potentially crashing teardown paths.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R650-651]

+                _textExchangeLock.Dispose();
                _disposed = true;
Evidence
Dispose disposes the semaphore, while ExecuteTextCommandAsync always releases it in a finally;
calling Release on a disposed SemaphoreSlim throws. Also, ExecuteTextCommandAsync waits on the
semaphore before it can reach the _disposed guard, so post-dispose calls can fail with
ObjectDisposedException instead of the intended InvalidOperationException.

src/Daqifi.Core/Device/DaqifiDevice.cs[364-378]
src/Daqifi.Core/Device/DaqifiDevice.cs[519-523]
src/Daqifi.Core/Device/DaqifiDevice.cs[642-651]

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

### Issue description
`Dispose()` calls `_textExchangeLock.Dispose()`, but `ExecuteTextCommandAsync` always calls `_textExchangeLock.Release()` in a `finally`. If `Dispose()` runs while a text exchange is active, the active exchange can hit `Release()` on a disposed semaphore and throw `ObjectDisposedException`, which can mask earlier exceptions/returns.

Additionally, after `Dispose()` has run, `ExecuteTextCommandAsync` will attempt `WaitAsync()` on a disposed semaphore before it can throw the intended `_disposed`/disconnecting `InvalidOperationException`.

### Issue Context
This is a shutdown/concurrency race: disposing a synchronization primitive used by in-flight operations is unsafe unless you can guarantee no waiters/owners.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[364-388]
- src/Daqifi.Core/Device/DaqifiDevice.cs[519-523]
- src/Daqifi.Core/Device/DaqifiDevice.cs[642-652]

### Suggested fix
Choose one safe strategy:
- **Simplest**: do not dispose `_textExchangeLock` in `Dispose()` (SemaphoreSlim disposal is optional), and add a fast `_disposed` check before `WaitAsync()` so disposed instances fail predictably.
- **If you must dispose**: ensure no in-flight exchanges by preventing new entrants (set `_disposed = true` first and add pre-`WaitAsync` `_disposed` check), then synchronously wait for the semaphore to be free before disposing it (and ensure no code calls `Release()` after disposal).

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


3. Reentrancy guard can miss ✓ Resolved 🐞 Bug ☼ Reliability
Description
ExecuteTextCommandAsync records the owner thread ID before awaiting and never refreshes it; after
ConfigureAwait(false)/await, setupAction can run on a different thread and a re-entrant call from
that callback will not be detected and will deadlock waiting on the held semaphore.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R352-366]

+            // Same-thread re-entrancy detection: a setupAction that calls
+            // ExecuteTextCommandAsync on the same device would corrupt the
+            // consumer swap mid-flight. Surface as a clean exception rather
+            // than wedging on _textExchangeLock.WaitAsync() forever.
+            var currentTid = Environment.CurrentManagedThreadId;
+            if (_textExchangeOwnerThreadId == currentTid)
            {
-                throw new InvalidOperationException("Device is not connected.");
+                throw new InvalidOperationException(
+                    "ExecuteTextCommandAsync is not re-entrant on the same device; "
+                    + "do not call it from inside a setupAction callback.");
            }

-            if (_transport == null)
+            await _textExchangeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+            _textExchangeOwnerThreadId = currentTid;
+            try
Evidence
The method captures currentTid and sets _textExchangeOwnerThreadId from it, but later awaits
before invoking setupAction, allowing execution to resume on a different thread; a recursive call
from setupAction will then see a different CurrentManagedThreadId and block on the semaphore
held by the outer call.

src/Daqifi.Core/Device/DaqifiDevice.cs[350-366]
src/Daqifi.Core/Device/DaqifiDevice.cs[364-366]
src/Daqifi.Core/Device/DaqifiDevice.cs[437-443]

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

### Issue description
`ExecuteTextCommandAsync` uses `Environment.CurrentManagedThreadId` as a same-thread re-entrancy guard, but it captures/stores the thread ID before async awaits and then later calls into `setupAction`. Because the method uses `ConfigureAwait(false)` and also awaits before invoking `setupAction`, the continuation (and `setupAction`) may run on a different thread than the captured owner ID, so a recursive call from `setupAction` won’t match `_textExchangeOwnerThreadId` and will block on `_textExchangeLock` held by the outer call.

### Issue Context
The intent is to fail fast for *re-entrant* calls (setupAction recursing) rather than deadlocking on the semaphore. In an async method, ManagedThreadId is not stable across awaits.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[352-366]
- src/Daqifi.Core/Device/DaqifiDevice.cs[437-443]

### Suggested fix
Update the approach so the re-entrancy guard is not dependent on a thread ID captured before awaits. Options:
1) Set/update `_textExchangeOwnerThreadId` immediately before invoking `setupAction` (and/or after any await that might change threads) so the guard matches the actual thread executing the callback.
2) Prefer a non-thread-based re-entrancy mechanism (e.g., a dedicated re-entrancy flag that’s set for the duration of the protected region and checked before waiting) to detect recursion reliably.

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



Remediation recommended

4. Disconnect blocks up to 5s ✓ Resolved 🐞 Bug ☼ Reliability
Description
Disconnect() now synchronously waits up to 5 seconds on _textExchangeLock, which can stall the
calling thread (and Dispose(), which calls Disconnect()) while a text exchange is in progress.
Because ExecuteTextCommandAsync performs awaits while holding the semaphore (e.g., Task.Delay
without ConfigureAwait(false)), calling Disconnect on a synchronization-context thread can
unnecessarily block that thread for the full timeout.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[241]

+                lockAcquired = _textExchangeLock.Wait(TimeSpan.FromSeconds(5));
Evidence
The new code introduces a synchronous 5-second wait on the text-exchange semaphore during
Disconnect(). ExecuteTextCommandAsync holds that same semaphore while performing awaits (including
Task.Delay), so Disconnect() can block until the exchange completes or the timeout elapses, stalling
the caller thread/Dispose path.

src/Daqifi.Core/Device/DaqifiDevice.cs[218-279]
src/Daqifi.Core/Device/DaqifiDevice.cs[486-508]

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

### Issue description
`Disconnect()` blocks synchronously for up to 5 seconds waiting for `_textExchangeLock`. This can stall `Dispose()`/`Disconnect()` callers and is especially problematic if `Disconnect()` is invoked on a synchronization-context thread while an in-flight `ExecuteTextCommandAsync` needs that context to make progress.

### Issue Context
- `Disconnect()` uses a blocking `Wait(TimeSpan.FromSeconds(5))`.
- `ExecuteTextCommandAsync` holds the semaphore while awaiting (`Task.Delay(...)`), and those awaits currently capture context by default.

### Fix options (pick one)
1. **Reduce/remove synchronous blocking in `Disconnect()`** (e.g., `Wait(0)`/shorter timeout + proceed, or make teardown async and expose `DisconnectAsync`).
2. **Ensure `ExecuteTextCommandAsync` does not depend on the caller's sync context** by adding `.ConfigureAwait(false)` to internal awaits (e.g., `Task.Delay(...)`) so it can complete/release the lock even if the UI thread is blocked.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[233-247]
- src/Daqifi.Core/Device/DaqifiDevice.cs[269-278]
- src/Daqifi.Core/Device/DaqifiDevice.cs[571-584]

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


5. Disconnect can race text exchange ✓ Resolved 🐞 Bug ☼ Reliability
Description
Disconnect() does not participate in _textExchangeLock, so it can stop the shared consumer and
disconnect the transport while ExecuteTextCommandAsync holds the semaphore and is mid swap/read,
leading to inconsistent consumer/stream state despite serialization among text commands.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R368-388]

+                // All validation runs INSIDE the lock so a competing thread
+                // calling DisconnectAsync() / Dispose() while we're blocked
+                // on WaitAsync() doesn't leave us with a stale _transport /
+                // _messageConsumer reference (closes the TOCTOU window
+                // documented in #186).
+                if (_disposed || _isDisconnecting)
+                {
+                    throw new InvalidOperationException(
+                        "ExecuteTextCommandAsync cannot run while the device is "
+                        + "disposing or disconnecting.");
+                }

-            cancellationToken.ThrowIfCancellationRequested();
+                if (!IsConnected)
+                {
+                    throw new InvalidOperationException("Device is not connected.");
+                }
+
+                if (_transport == null)
+                {
+                    throw new InvalidOperationException("ExecuteTextCommandAsync requires a transport-based connection.");
+                }
Evidence
Disconnect stops/unsubscribes the protobuf consumer and disconnects the transport without acquiring
_textExchangeLock, while ExecuteTextCommandAsync concurrently stops/starts _messageConsumer and
performs stream I/O under the semaphore; these two operations can interleave and corrupt the
intended pause/swap/restart sequence.

src/Daqifi.Core/Device/DaqifiDevice.cs[220-244]
src/Daqifi.Core/Device/DaqifiDevice.cs[368-388]
src/Daqifi.Core/Device/DaqifiDevice.cs[411-513]

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

### Issue description
`ExecuteTextCommandAsync` now serializes text exchanges, but `Disconnect()` still manipulates `_messageConsumer` and `_transport` without taking `_textExchangeLock`. This means Disconnect can interleave with an in-flight text exchange and disrupt the consumer stop/start or disconnect the stream being used.

### Issue Context
The new `_isDisconnecting` guard only prevents starting an exchange if the flag is observed at the validation point; it does not prevent `Disconnect()` from tearing down while an exchange is already in progress.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[220-244]
- src/Daqifi.Core/Device/DaqifiDevice.cs[368-388]
- src/Daqifi.Core/Device/DaqifiDevice.cs[411-513]

### Suggested fix
Make teardown participate in the same mutual exclusion:
- In `Disconnect()`, set `_isDisconnecting = true`, then synchronously `Wait()` the semaphore before stopping consumers/transport; release it in a `finally`, and then clear `_isDisconnecting`.
- Ensure `Dispose()` and `Disconnect()` don’t deadlock each other (e.g., have `Dispose()` call a private teardown method that assumes the semaphore is already held).

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



Advisory comments

6. Misleading lock release comment ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
A new comment in Disconnect() states the lock is NOT released, but the method does release
_textExchangeLock when acquired. This contradiction can mislead maintainers about the
locking/teardown contract.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R233-237]

+            // Best-effort coordination with ExecuteTextCommandAsync. We do
+            // NOT release this lock — Dispose() disposes the semaphore
+            // shortly after, and any in-flight text exchange will see
+            // _isDisconnecting on its own validation path or take the
+            // ObjectDisposedException catch in its Release().
Evidence
The comment explicitly claims the lock is not released, but the code later calls
_textExchangeLock.Release() when lockAcquired is true.

src/Daqifi.Core/Device/DaqifiDevice.cs[232-278]

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

### Issue description
In `Disconnect()`, the comment says “We do NOT release this lock”, but the code conditionally releases `_textExchangeLock` in the `finally` block when it was acquired. This is misleading.

### Issue Context
The current implementation correctly releases the semaphore, but the comment suggests otherwise.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[233-247]
- src/Daqifi.Core/Device/DaqifiDevice.cs[269-278]

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


Grey Divider

Previous review results

Review updated until commit 2f23625

Results up to commit f373f1a


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)


Action required
1. Semaphore disposed while in use ✓ Resolved 🐞 Bug ☼ Reliability
Description
Dispose() disposes _textExchangeLock even if an ExecuteTextCommandAsync is in-flight, so the
in-flight call can throw ObjectDisposedException from its finally on Release(), masking the
original outcome and potentially crashing teardown paths.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R650-651]

+                _textExchangeLock.Dispose();
                _disposed = true;
Evidence
Dispose disposes the semaphore, while ExecuteTextCommandAsync always releases it in a finally;
calling Release on a disposed SemaphoreSlim throws. Also, ExecuteTextCommandAsync waits on the
semaphore before it can reach the _disposed guard, so post-dispose calls can fail with
ObjectDisposedException instead of the intended InvalidOperationException.

src/Daqifi.Core/Device/DaqifiDevice.cs[364-378]
src/Daqifi.Core/Device/DaqifiDevice.cs[519-523]
src/Daqifi.Core/Device/DaqifiDevice.cs[642-651]

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

### Issue description
`Dispose()` calls `_textExchangeLock.Dispose()`, but `ExecuteTextCommandAsync` always calls `_textExchangeLock.Release()` in a `finally`. If `Dispose()` runs while a text exchange is active, the active exchange can hit `Release()` on a disposed semaphore and throw `ObjectDisposedException`, which can mask earlier exceptions/returns.

Additionally, after `Dispose()` has run, `ExecuteTextCommandAsync` will attempt `WaitAsync()` on a disposed semaphore before it can throw the intended `_disposed`/disconnecting `InvalidOperationException`.

### Issue Context
This is a shutdown/concurrency race: disposing a synchronization primitive used by in-flight operations is unsafe unless you can guarantee no waiters/owners.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[364-388]
- src/Daqifi.Core/Device/DaqifiDevice.cs[519-523]
- src/Daqifi.Core/Device/DaqifiDevice.cs[642-652]

### Suggested fix
Choose one safe strategy:
- **Simplest**: do not dispose `_textExchangeLock` in `Dispose()` (SemaphoreSlim disposal is optional), and add a fast `_disposed` check before `WaitAsync()` so disposed instances fail predictably.
- **If you must dispose**: ensure no in-flight exchanges by preventing new entrants (set `_disposed = true` first and add pre-`WaitAsync` `_disposed` check), then synchronously wait for the semaphore to be free before disposing it (and ensure no code calls `Release()` after disposal).

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


2. Reentrancy guard can miss ✓ Resolved 🐞 Bug ☼ Reliability
Description
ExecuteTextCommandAsync records the owner thread ID before awaiting and never refreshes it; after
ConfigureAwait(false)/await, setupAction can run on a different thread and a re-entrant call from
that callback will not be detected and will deadlock waiting on the held semaphore.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R352-366]

+            // Same-thread re-entrancy detection: a setupAction that calls
+            // ExecuteTextCommandAsync on the same device would corrupt the
+            // consumer swap mid-flight. Surface as a clean exception rather
+            // than wedging on _textExchangeLock.WaitAsync() forever.
+            var currentTid = Environment.CurrentManagedThreadId;
+            if (_textExchangeOwnerThreadId == currentTid)
            {
-                throw new InvalidOperationException("Device is not connected.");
+                throw new InvalidOperationException(
+                    "ExecuteTextCommandAsync is not re-entrant on the same device; "
+                    + "do not call it from inside a setupAction callback.");
            }

-            if (_transport == null)
+            await _textExchangeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+            _textExchangeOwnerThreadId = currentTid;
+            try
Evidence
The method captures currentTid and sets _textExchangeOwnerThreadId from it, but later awaits
before invoking setupAction, allowing execution to resume on a different thread; a recursive call
from setupAction will then see a different CurrentManagedThreadId and block on the semaphore
held by the outer call.

src/Daqifi.Core/Device/DaqifiDevice.cs[350-366]
src/Daqifi.Core/Device/DaqifiDevice.cs[364-366]
src/Daqifi.Core/Device/DaqifiDevice.cs[437-443]

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

### Issue description
`ExecuteTextCommandAsync` uses `Environment.CurrentManagedThreadId` as a same-thread re-entrancy guard, but it captures/stores the thread ID before async awaits and then later calls into `setupAction`. Because the method uses `ConfigureAwait(false)` and also awaits before invoking `setupAction`, the continuation (and `setupAction`) may run on a different thread than the captured owner ID, so a recursive call from `setupAction` won’t match `_textExchangeOwnerThreadId` and will block on `_textExchangeLock` held by the outer call.

### Issue Context
The intent is to fail fast for *re-entrant* calls (setupAction recursing) rather than deadlocking on the semaphore. In an async method, ManagedThreadId is not stable across awaits.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[352-366]
- src/Daqifi.Core/Device/DaqifiDevice.cs[437-443]

### Suggested fix
Update the approach so the re-entrancy guard is not dependent on a thread ID captured before awaits. Options:
1) Set/update `_textExchangeOwnerThreadId` immediately before invoking `setupAction` (and/or after any await that might change threads) so the guard matches the actual thread executing the callback.
2) Prefer a non-thread-based re-entrancy mechanism (e.g., a dedicated re-entrancy flag that’s set for the duration of the protected region and checked before waiting) to detect recursion reliably.

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



Remediation recommended
3. Disconnect can race text exchange ✓ Resolved 🐞 Bug ☼ Reliability
Description
Disconnect() does not participate in _textExchangeLock, so it can stop the shared consumer and
disconnect the transport while ExecuteTextCommandAsync holds the semaphore and is mid swap/read,
leading to inconsistent consumer/stream state despite serialization among text commands.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R368-388]

+                // All validation runs INSIDE the lock so a competing thread
+                // calling DisconnectAsync() / Dispose() while we're blocked
+                // on WaitAsync() doesn't leave us with a stale _transport /
+                // _messageConsumer reference (closes the TOCTOU window
+                // documented in #186).
+                if (_disposed || _isDisconnecting)
+                {
+                    throw new InvalidOperationException(
+                        "ExecuteTextCommandAsync cannot run while the device is "
+                        + "disposing or disconnecting.");
+                }

-            cancellationToken.ThrowIfCancellationRequested();
+                if (!IsConnected)
+                {
+                    throw new InvalidOperationException("Device is not connected.");
+                }
+
+                if (_transport == null)
+                {
+                    throw new InvalidOperationException("ExecuteTextCommandAsync requires a transport-based connection.");
+                }
Evidence
Disconnect stops/unsubscribes the protobuf consumer and disconnects the transport without acquiring
_textExchangeLock, while ExecuteTextCommandAsync concurrently stops/starts _messageConsumer and
performs stream I/O under the semaphore; these two operations can interleave and corrupt the
intended pause/swap/restart sequence.

src/Daqifi.Core/Device/DaqifiDevice.cs[220-244]
src/Daqifi.Core/Device/DaqifiDevice.cs[368-388]
src/Daqifi.Core/Device/DaqifiDevice.cs[411-513]

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

### Issue description
`ExecuteTextCommandAsync` now serializes text exchanges, but `Disconnect()` still manipulates `_messageConsumer` and `_transport` without taking `_textExchangeLock`. This means Disconnect can interleave with an in-flight text exchange and disrupt the consumer stop/start or disconnect the stream being used.

### Issue Context
The new `_isDisconnecting` guard only prevents starting an exchange if the flag is observed at the validation point; it does not prevent `Disconnect()` from tearing down while an exchange is already in progress.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[220-244]
- src/Daqifi.Core/Device/DaqifiDevice.cs[368-388]
- src/Daqifi.Core/Device/DaqifiDevice.cs[411-513]

### Suggested fix
Make teardown participate in the same mutual exclusion:
- In `Disconnect()`, set `_isDisconnecting = true`, then synchronously `Wait()` the semaphore before stopping consumers/transport; release it in a `finally`, and then clear `_isDisconnecting`.
- Ensure `Dispose()` and `Disconnect()` don’t deadlock each other (e.g., have `Dispose()` call a private teardown method that assumes the semaphore is already held).

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


Results up to commit f7efc6c


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)


Remediation recommended
1. Disconnect blocks up to 5s ✓ Resolved 🐞 Bug ☼ Reliability
Description
Disconnect() now synchronously waits up to 5 seconds on _textExchangeLock, which can stall the
calling thread (and Dispose(), which calls Disconnect()) while a text exchange is in progress.
Because ExecuteTextCommandAsync performs awaits while holding the semaphore (e.g., Task.Delay
without ConfigureAwait(false)), calling Disconnect on a synchronization-context thread can
unnecessarily block that thread for the full timeout.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[241]

+                lockAcquired = _textExchangeLock.Wait(TimeSpan.FromSeconds(5));
Evidence
The new code introduces a synchronous 5-second wait on the text-exchange semaphore during
Disconnect(). ExecuteTextCommandAsync holds that same semaphore while performing awaits (including
Task.Delay), so Disconnect() can block until the exchange completes or the timeout elapses, stalling
the caller thread/Dispose path.

src/Daqifi.Core/Device/DaqifiDevice.cs[218-279]
src/Daqifi.Core/Device/DaqifiDevice.cs[486-508]

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

### Issue description
`Disconnect()` blocks synchronously for up to 5 seconds waiting for `_textExchangeLock`. This can stall `Dispose()`/`Disconnect()` callers and is especially problematic if `Disconnect()` is invoked on a synchronization-context thread while an in-flight `ExecuteTextCommandAsync` needs that context to make progress.

### Issue Context
- `Disconnect()` uses a blocking `Wait(TimeSpan.FromSeconds(5))`.
- `ExecuteTextCommandAsync` holds the semaphore while awaiting (`Task.Delay(...)`), and those awaits currently capture context by default.

### Fix options (pick one)
1. **Reduce/remove synchronous blocking in `Disconnect()`** (e.g., `Wait(0)`/shorter timeout + proceed, or make teardown async and expose `DisconnectAsync`).
2. **Ensure `ExecuteTextCommandAsync` does not depend on the caller's sync context** by adding `.ConfigureAwait(false)` to internal awaits (e.g., `Task.Delay(...)`) so it can complete/release the lock even if the UI thread is blocked.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[233-247]
- src/Daqifi.Core/Device/DaqifiDevice.cs[269-278]
- src/Daqifi.Core/Device/DaqifiDevice.cs[571-584]

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



Advisory comments
2. Misleading lock release comment ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
A new comment in Disconnect() states the lock is NOT released, but the method does release
_textExchangeLock when acquired. This contradiction can mislead maintainers about the
locking/teardown contract.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R233-237]

+            // Best-effort coordination with ExecuteTextCommandAsync. We do
+            // NOT release this lock — Dispose() disposes the semaphore
+            // shortly after, and any in-flight text exchange will see
+            // _isDisconnecting on its own validation path or take the
+            // ObjectDisposedException catch in its Release().
Evidence
The comment explicitly claims the lock is not released, but the code later calls
_textExchangeLock.Release() when lockAcquired is true.

src/Daqifi.Core/Device/DaqifiDevice.cs[232-278]

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

### Issue description
In `Disconnect()`, the comment says “We do NOT release this lock”, but the code conditionally releases `_textExchangeLock` in the `finally` block when it was acquired. This is misleading.

### Issue Context
The current implementation correctly releases the semaphore, but the comment suggests otherwise.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[233-247]
- src/Daqifi.Core/Device/DaqifiDevice.cs[269-278]

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


Results up to commit 7dc53d3


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)


Action required
1. Disconnect wait too short ✓ Resolved 🐞 Bug ☼ Reliability
Description
Disconnect() only waits 1 second for _textExchangeLock, but ExecuteTextCommandAsync can hold the
lock for several seconds (protobuf StopSafely up to 1s + response loop up to responseTimeoutMs*5,
default 5s). This often causes Disconnect/Dispose to proceed without synchronization and tear down
the transport/consumers while a text exchange is still running, reintroducing the race the semaphore
is meant to prevent.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R222-247]

+        /// Waits up to 1 second to acquire <c>_textExchangeLock</c> before
+        /// tearing down the consumer / producer / transport. This prevents
+        /// a race where an in-flight <see cref="ExecuteTextCommandAsync"/>
+        /// 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
+        /// anyway — a stuck text exchange must not block teardown forever.
+        /// The 1s budget is the longest delay any normal text exchange
+        /// can hold the lock for (responseTimeoutMs default + safety
+        /// margin); callers wanting non-blocking disconnect should drive
+        /// this off a Task.Run.
+        /// </remarks>
        public void Disconnect()
        {
            _isDisconnecting = true;
+            // Best-effort coordination with ExecuteTextCommandAsync —
+            // acquire the lock so we don't tear the transport out from
+            // under an in-flight text exchange. The lock IS released in
+            // the finally below when acquired (so a future Connect()
+            // followed by ExecuteTextCommandAsync isn't blocked); a
+            // stuck exchange that holds past the timeout drops to the
+            // _isDisconnecting validation path inside the exchange.
+            var lockAcquired = false;
+            try
+            {
+                lockAcquired = _textExchangeLock.Wait(TimeSpan.FromSeconds(1));
Evidence
Disconnect’s 1s wait is shorter than the time the text-exchange path can hold the semaphore (default
timeouts), so Disconnect/Dispose can proceed without the lock and tear down resources mid-exchange.

src/Daqifi.Core/Device/DaqifiDevice.cs[218-248]
src/Daqifi.Core/Device/DaqifiDevice.cs[380-385]
src/Daqifi.Core/Device/DaqifiDevice.cs[467-478]
src/Daqifi.Core/Device/DaqifiDevice.cs[506-520]
src/Daqifi.Core/Device/DaqifiDevice.cs[717-726]

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

### Issue description
`Disconnect()` only waits 1 second to acquire `_textExchangeLock`, but `ExecuteTextCommandAsync` can hold the lock for significantly longer under normal timeout paths. This means `Disconnect()`/`Dispose()` can still tear down the transport mid-text-exchange.

### Issue Context
The new code intends to coordinate teardown with in-flight text exchanges. However, the 1s wait is shorter than the exchange’s own worst-case duration (default timeouts).

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[222-232]
- src/Daqifi.Core/Device/DaqifiDevice.cs[237-248]

### Suggested fix
- Increase the `Wait(...)` budget to cover the actual worst-case lock hold time for a default text exchange (e.g., >= `StopSafely(1000ms)` + `responseTimeoutMs*5` + a small margin), or make the timeout configurable/derived from shared constants.
- Update the remarks to match the real maximum hold time (or rephrase as “best-effort wait”).

ⓘ 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 Outdated
Comment thread src/Daqifi.Core/Device/DaqifiDevice.cs
…c concurrency

Three concurrency findings from Qodo on the lock-coordination work:

1. Re-entrancy guard could miss across async hops. The previous design
   captured Environment.CurrentManagedThreadId before await, but after
   ConfigureAwait(false) the resumed thread may differ — a setupAction
   callback re-entering ExecuteTextCommandAsync could then deadlock
   on _textExchangeLock.WaitAsync() instead of throwing. Replaced the
   int? owner-thread tracker with AsyncLocal<bool>, which flows
   through async resumptions regardless of which thread picks them up.

2. Disconnect() did not coordinate with the text-exchange lock. An
   in-flight ExecuteTextCommandAsync could be mid-swap (text consumer
   running, protobuf consumer not yet restarted) when Disconnect ripped
   the transport out from under it. Disconnect now waits up to 5s to
   acquire _textExchangeLock before tearing down. If the wait times
   out, teardown proceeds anyway — a stuck text exchange must not
   block disconnect forever.

3. Dispose() disposed _textExchangeLock while an in-flight call could
   still hold it; the in-flight call's finally Release() would then
   throw ObjectDisposedException and mask the real exception. Wrapped
   the Release in a try/catch so disposal-during-flight is treated as
   a benign teardown signal.

Tests updated to flip the AsyncLocal flag instead of poking the
removed int? field. 895/897 pass (2 skipped require live hardware).
@cptkoolbeenz
Copy link
Copy Markdown
Member Author

/improve

@cptkoolbeenz
Copy link
Copy Markdown
Member Author

/agentic_review

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 12, 2026

Code Review by Qodo

Grey Divider

New Review Started

This review has been superseded by a new analysis

Grey Divider

Qodo Logo

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 12, 2026

PR Code Suggestions ✨

Latest suggestions up to 2f23625

Warning

/improve is deprecated. Use /agentic_review instead (removal date not yet scheduled).

CategorySuggestion                                                                                                                                    Impact
Possible issue
Serialize dispose with exchanges

Add best-effort lock acquisition in Dispose() to safely coordinate with
in-flight commands before tearing down resources.

src/Daqifi.Core/Device/DaqifiDevice.cs [198-205]

 public void Dispose()
 {
-    if (!_disposed)
+    if (_disposed)
+        return;
+
+    _isDisconnecting = true;
+
+    var lockAcquired = false;
+    try
+    {
+        // Best-effort: avoid tearing down transport mid text-exchange.
+        lockAcquired = _textExchangeLock.Wait(TimeSpan.FromSeconds(10));
+    }
+    catch (ObjectDisposedException)
+    {
+        // Nothing to coordinate with; continue teardown.
+    }
+
+    try
     {
         _messageConsumer?.Dispose();
         _messageProducer?.Dispose();
         _transport?.Dispose();
         _textExchangeLock.Dispose();
         _disposed = true;
     }
+    finally
+    {
+        if (lockAcquired)
+        {
+            try { _textExchangeLock.Release(); } catch (ObjectDisposedException) { }
+        }
+    }
 }

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 10

__

Why: Excellent catch; this correctly identifies that Dispose() is missing the lock acquisition logic explicitly referenced in the PR comments for ExecuteTextCommandAsync, preventing potential teardown race conditions.

High
  • More

Previous suggestions

Suggestions up to commit 7dc53d3
CategorySuggestion                                                                                                                                    Impact
Possible issue
Prevent lock leaks on disconnect

Move the _textExchangeLock release and _isDisconnecting reset logic into a
finally block in Disconnect().

src/Daqifi.Core/Device/DaqifiDevice.cs [43-80]

 public void Disconnect()
 {
     _isDisconnecting = true;
-    // Best-effort coordination with ExecuteTextCommandAsync —
-    // acquire the lock so we don't tear the transport out from
-    // under an in-flight text exchange. The lock IS released in
-    // the finally below when acquired (so a future Connect()
-    // followed by ExecuteTextCommandAsync isn't blocked); a
-    // stuck exchange that holds past the timeout drops to the
-    // _isDisconnecting validation path inside the exchange.
+
     var lockAcquired = false;
     try
     {
         lockAcquired = _textExchangeLock.Wait(TimeSpan.FromSeconds(1));
     }
     catch (ObjectDisposedException)
     {
-        // Disconnect called after Dispose — nothing to coordinate.
     }
 
     try
     {
         // Unsubscribe from message consumer events
         ...
         State = DeviceState.Disconnected;
         _isInitialized = false;
+    }
+    finally
+    {
         _isDisconnecting = false;
+
         if (lockAcquired)
         {
             try
             {
                 _textExchangeLock.Release();
             }
             catch (ObjectDisposedException)
             {
             }
         }
     }
 }
Suggestion importance[1-10]: 10

__

Why: Excellent catch: the PR author explicitly mentions releasing the lock in a finally block in the comments, but placed the release logic inside the try block, risking a permanent lock leak if an exception occurs during teardown.

High
Suggestions up to commit 328e212
CategorySuggestion                                                                                                                                    Impact
Possible issue
Handle disposed-lock wait failures

Wrap _textExchangeLock.WaitAsync in a try/catch block to handle
ObjectDisposedException. Convert the caught exception into an
InvalidOperationException to maintain consistent failure modes.

src/Daqifi.Core/Device/DaqifiDevice.cs [95-109]

-await _textExchangeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+try
+{
+    await _textExchangeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+}
+catch (ObjectDisposedException)
+{
+    throw new InvalidOperationException(
+        "ExecuteTextCommandAsync cannot run because the device is disposed.");
+}
+
 _isInsideTextExchange.Value = true;
 try
 {
     // All validation runs INSIDE the lock so a competing thread
     // calling DisconnectAsync() / Dispose() while we're blocked
     // on WaitAsync() doesn't leave us with a stale _transport /
     // _messageConsumer reference (closes the TOCTOU window
     // documented in #186).
     if (_disposed || _isDisconnecting)
     {
         throw new InvalidOperationException(
             "ExecuteTextCommandAsync cannot run while the device is "
             + "disposing or disconnecting.");
     }
Suggestion importance[1-10]: 8

__

Why: Properly handles the ObjectDisposedException from WaitAsync by translating it into an InvalidOperationException, preventing unhandled low-level exceptions from leaking during teardown.

Medium

…tion

Qodo flagged the symmetric race to the post-acquisition disposal
handling: _textExchangeLock.WaitAsync() itself can throw
ObjectDisposedException if Dispose() ran ahead of an arriving caller.
Without a catch, the low-level teardown exception leaks to callers
instead of the InvalidOperationException they get from the
post-acquisition _disposed check. Wrap the WaitAsync and translate
to the same "device is disposed" failure mode for consistency.
@cptkoolbeenz
Copy link
Copy Markdown
Member Author

/improve

@cptkoolbeenz
Copy link
Copy Markdown
Member Author

/agentic_review

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 12, 2026

Persistent review updated to latest commit f7efc6c

@qodo-code-review
Copy link
Copy Markdown

PR Code Suggestions ✨

Warning

/improve is deprecated. Use /agentic_review instead (removed after 2026-05-31).

No code suggestions found for the PR.

…, ConfigureAwait, fix comment

Two findings from Qodo review on the prior pass:

1. Disconnect blocks up to 5s. Reduced the SemaphoreSlim.Wait timeout
   from 5 to 1 second. The 1s budget covers the longest delay any
   normal text exchange can hold the lock for (responseTimeoutMs
   default + safety margin), without making sync Disconnect calls
   stall callers for an unreasonable wait. Callers wanting truly
   non-blocking disconnect should drive it from Task.Run.

   Combined with this: the two Task.Delay(50) calls inside
   ExecuteTextCommandAsync now use ConfigureAwait(false). Without it,
   the awaits resume on the captured sync context — and a sync
   Disconnect() called on that same UI thread would deadlock waiting
   for a lock the exchange can't release because its continuation
   can't reach the finally.

2. Misleading lock release comment. The previous comment said
   "We do NOT release this lock", which contradicted the finally
   block that does release on lockAcquired. Rewrote the comment to
   describe the actual contract.
@cptkoolbeenz
Copy link
Copy Markdown
Member Author

/improve

@cptkoolbeenz
Copy link
Copy Markdown
Member Author

/agentic_review

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 12, 2026

Persistent review updated to latest commit 7dc53d3

@qodo-code-review
Copy link
Copy Markdown

Persistent suggestions updated to latest commit 7dc53d3

cptkoolbeenz added a commit that referenced this pull request May 12, 2026
…ncy guard

Qodo flagged a real deadlock: UpdateWifiModuleAsync holds _operationLock
while synchronously firing progress.Report. If a handler calls
CheckWifiFirmwareStatusAsync from inside that callback, WaitAsync would
block waiting for the lock its own caller's flow already holds —
SemaphoreSlim is not re-entrant.

Added AsyncLocal<bool> _isInsideOperation flag, set true by both lock
acquisition sites (CheckWifiFirmwareStatusAsync and RunExclusiveAsync).
CheckWifiFirmwareStatusAsync now skips the lock acquisition when the
flag is already true on the current async flow — safe because the
outer flow already provides exclusive device-I/O access.

AsyncLocal flows through await resumptions on different threads, so
this catches re-entrancy even when the inner call resumes on a
different thread than the outer (mirrors the AsyncLocal pattern from
PR #196's ExecuteTextCommandAsync hardening).

Regression test added: invokes CheckWifiFirmwareStatusAsync from
inside a progress.Report callback during UpdateWifiModuleAsync. Without
the guard the test hangs on the deadlocked semaphore; with the guard
the inner call returns immediately and the update completes normally.
@cptkoolbeenz
Copy link
Copy Markdown
Member Author

/improve

@cptkoolbeenz
Copy link
Copy Markdown
Member Author

/agentic_review

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 12, 2026

Persistent review updated to latest commit 7dc53d3

@qodo-code-review
Copy link
Copy Markdown

PR Code Suggestions ✨

Warning

/improve is deprecated. Use /agentic_review instead (removed after 2026-05-31).

No code suggestions found for the PR.

Comment thread src/Daqifi.Core/Device/DaqifiDevice.cs Outdated
The 1-second wait was shorter than the worst-case ExecuteTextCommandAsync
hold time (StopSafely up to 1s + maxWait of responseTimeoutMs*5 = 5s by
default, longer with custom timeouts), so Disconnect/Dispose could rip
the transport out from under an in-flight text exchange — reintroducing
the race the lock is meant to prevent.

10s budget covers the default-timeout case with margin and most custom
callers; on timeout the in-flight exchange still bails cleanly via the
post-acquisition _isDisconnecting check, so teardown stays bounded.
@cptkoolbeenz
Copy link
Copy Markdown
Member Author

/improve

@cptkoolbeenz
Copy link
Copy Markdown
Member Author

/agentic_review

@qodo-code-review
Copy link
Copy Markdown

qodo-code-review Bot commented May 12, 2026

Persistent review updated to latest commit 2f23625

@qodo-code-review
Copy link
Copy Markdown

Persistent suggestions updated to latest commit 2f23625

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.

Serialize ExecuteTextCommandAsync to prevent concurrent text-exchange interference

1 participant