Skip to content

fix(device): an opt-in connect that leaves a running stream alone (closes #385) - #414

Merged
tylerkron merged 4 commits into
mainfrom
fix/second-connection-stops-stream
Jul 31, 2026
Merged

fix(device): an opt-in connect that leaves a running stream alone (closes #385)#414
tylerkron merged 4 commits into
mainfrom
fix/second-connection-stops-stream

Conversation

@tylerkron

@tylerkron tylerkron commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

closes #385

Not merging — for review.

Why

A DAQiFi box has one acquisition. One converter, one sample rate, one place the data goes. There is no such thing as "my stream" versus "your stream" — there is only the stream.

Connecting to a device tells it to stop streaming, as part of the standard setup handshake. That is the right thing to do when you are the only one using the box: it clears out anything a previously crashed session left running, so you start clean.

But if somebody else is already recording from that box, connecting ends their recording. Their data just stops. Nothing tells them, and nothing tells you. Plug in over USB while a script is logging over WiFi, open a second copy of the app, let a colleague peek at a unit you are logging from — same result every time.

What

Connecting can now be told to leave a running acquisition alone.

// I just want to look — don't disturb whoever is already recording.
var device = await DaqifiDeviceFactory.ConnectTcpAsync(ip, 9760, DeviceConnectionOptions.Observing);

The observing session connects normally and is fully usable: it reads the device's serial number, firmware, and all 32 channels, and reports Ready like any other connection. What it does not do is touch the acquisition — it will not stop it, will not change its format, and will not redirect it to itself. Since it leaves the stream configured for whoever owns it, an observer is not itself set up to record; a session that later wants to record has to take control, which necessarily stops the other one.

Nothing existing changes. Connecting still takes control of the device by default, exactly as before. This is purely opt-in.

The hazard is also now written down where people will actually hit it — in the README connection section and in docs/DEVICE_INTERFACES.md, with a table of which setup commands the observing path skips, and a pointer to DaqifiDeviceRegistry for apps that want to stop the same box from being opened twice in the first place.

How

InitializeAsync sends five setup commands. Three of them write the device's global stream state (stop streaming, set power state, set stream format), and over USB a fourth points the stream at this connection. A new PreserveActiveStream flag skips exactly those four. What is left is turning off command echo — a text-mode setting, unrelated to streaming — and the read-only queries that fetch the device's identity and capabilities.

Two ways to set it: DeviceConnectionOptions.PreserveActiveStream (or the DeviceConnectionOptions.Observing preset) for the factory and the registry, or DaqifiDevice.PreserveActiveStream when you build the connection by hand. It is read once, when initialization runs.

Why this direction

The issue listed several options. This is the smallest one that actually closes the gap: no new types, no change to how anything behaves today, no attempt to guess whether a caller "intends to stream" — the caller simply says which kind of session it is opening. The alternatives either changed existing behavior for everyone or required inferring intent the caller already knows.

What this does not fix

This is a courtesy between two sessions using this library, not arbitration. It stops Core from clobbering a stream; it cannot stop anything else from doing so, and both sides have to opt in. A real cross-process fix has to come from the firmware — refusing or announcing a second controlling session while streaming. Worth a companion firmware issue if we want to go further; not filed here, since this PR does not depend on it.

One compile-time break

The protected hook OnDeviceInitializingAsync now takes the preserve-stream decision as a parameter instead of reading it off a field. Anyone who subclasses DaqifiDevice and overrides that hook has to add the parameter. DaqifiStreamingDevice is the only overrider in this repo. It is a loud, one-line fix rather than a silent behavior change, and the library is 0.x — but it is a break, so it belongs in the notes.

Testing

Unit: the default path is asserted to still send the full sequence; the opt-in path is asserted to omit every stream-touching command, skip the USB routing step, and still reach Ready with populated channels. Full suite green on net9.0 and net10.0 (2196 Core + 23 MCP), Release build with zero warnings.

Hardware, Nyquist 1 (HW 2.0.0, FW 3.7.2, SN 9090539562006014104) reachable over USB and WiFi simultaneously — the exact repro from the issue. USB streaming at 100 Hz, second session connecting over TCP mid-stream:

Before the 2nd connect After
Default connect (today's behavior) 79, 79, 80, 79, 80, 79 frames/s 42, 0, 0, 0, 0, 0, 0, 0, 0, 0
PreserveActiveStream 79, 79, 80, 79, 80, 79 frames/s 278*, 80, 79, 80, 79, 79, 80, 79, 80, 79

* one bucket covering the 2.5 s connect plus the following second — 3.5 s × 79/s, i.e. not a single frame lost.

In both runs the second session itself connected fine (Ready, 32 channels, correct serial). Also verified: an observing connect to an idle device still reaches Ready with 32 channels and correct firmware in 3.0 s (confirming that skipping the power-state command does not leave a usable session behind), and the device streamed normally at 200 Hz afterwards with WiFi still answering. Frame rates run at ~79% of the requested rate throughout — that is the known firmware clock offset on this unit, not an artifact of this change.

🤖 Generated with Claude Code

A DAQiFi unit has one global acquisition, and InitializeAsync unconditionally
sends SYSTem:StopStreamData (plus POWer:STATe, STReam:FORmat, and the USB
STReam:INTerface routing step). A second session connecting to a device that is
already streaming therefore ends the first session's acquisition, silently.

Adds DeviceConnectionOptions.PreserveActiveStream (and the matching
DaqifiDevice property, plus a DeviceConnectionOptions.Observing preset) which
skips exactly the initialization commands that write global stream state. The
session still sends SYSTem:ECHO -1 and the read-only identity/capability
queries, so it reaches Ready with populated channels. Default behavior is
unchanged.

Also documents the hazard in the README and DEVICE_INTERFACES, and points
multi-session consumers at DaqifiDeviceRegistry.

Closes #385
@tylerkron
tylerkron requested a review from a team as a code owner July 31, 2026 19:30
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add opt-in observing connection to preserve an active device stream

🐞 Bug fix ✨ Enhancement 📝 Documentation 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Add PreserveActiveStream / Observing option to connect without stopping an active acquisition.
• Update initialization to skip global stream/USB-routing commands when observing.
• Document multi-session hazard and add tests to lock in default and observing behaviors.
Diagram

graph TD
  A["Caller"] --> B["DeviceConnectionOptions"] --> C["DaqifiDeviceFactory"] --> D["DaqifiDevice.InitializeAsync"] --> E{"Preserve\nActiveStream?"}
  E -->|"Yes (Observing)"| F["Echo off + identity queries"]
  E -->|"No (Default)"| G["Stop/power/format stream"]
  D --> H["DaqifiStreamingDevice\n(USB routing step)"]
  E -->|"Yes"| I["Skip USB routing"]
  E -->|"No"| J["Route stream to USB"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Device-side arbitration / firmware-enforced session ownership
  • ➕ Prevents silent stream disruption across all clients, not just this library
  • ➕ Could allow explicit “take control” vs “observe” semantics with device feedback
  • ➖ Requires firmware changes and deployment coordination
  • ➖ May be infeasible for already-shipped devices or mixed firmware fleets
2. Auto-detect active streaming and require explicit takeover
  • ➕ Makes the dangerous behavior harder to trigger accidentally
  • ➕ Keeps default control flow available but forces an explicit choice when risk is detected
  • ➖ Requires a reliable way to query “stream active” and interface destination early
  • ➖ Adds UX/API complexity and may still race with other processes
3. Process-wide/host-wide lock (e.g., named mutex) around a physical device
  • ➕ Prevents accidental double-open within a machine even across processes
  • ➕ Does not require firmware changes
  • ➖ Does not help across machines (USB + WiFi from another host)
  • ➖ Extra infrastructure and mapping needed from device identity to lock key

Recommendation: The PR’s approach (opt-in PreserveActiveStream / Observing, default unchanged) is the best near-term fix because it mitigates the real-world hazard without breaking existing single-session workflows. Consider a future follow-up for stronger protection: either an optional “detect-and-confirm takeover” API if the firmware exposes reliable stream-status queries, or host-level locking for multi-process scenarios. Firmware arbitration would be ideal but is a longer path.

Files changed (8) +288 / -2

Enhancement (3) +116 / -1
DaqifiDevice.csIntroduce PreserveActiveStream flag and gate init stream setup +71/-0

Introduce PreserveActiveStream flag and gate init stream setup

• Adds 'PreserveActiveStream' with detailed semantics and introduces 'InitializationPreservesActiveStream' as a snapshot for the current init attempt. Updates 'InitializeAsync' to always disable echo, but to skip stop/power/format steps when preserving an active stream.

src/Daqifi.Core/Device/DaqifiDevice.cs

DaqifiDeviceFactory.csPlumb PreserveActiveStream from options into constructed devices +8/-1

Plumb PreserveActiveStream from options into constructed devices

• Copies 'PreserveActiveStream' through effective connection options and ensures the created device instance has the flag set before 'Connect()'/'InitializeAsync' runs. This makes factory-based connections honor the observing behavior consistently across transports.

src/Daqifi.Core/Device/DaqifiDeviceFactory.cs

DeviceConnectionOptions.csAdd PreserveActiveStream option and Observing preset +37/-0

Add PreserveActiveStream option and Observing preset

• Introduces 'PreserveActiveStream' to control whether initialization may modify global stream state. Adds a convenience 'Observing' preset that enables the flag for secondary, non-disruptive connections.

src/Daqifi.Core/Device/DeviceConnectionOptions.cs

Bug fix (1) +13 / -0
DaqifiStreamingDevice.csSkip USB stream-interface routing when preserving active stream +13/-0

Skip USB stream-interface routing when preserving active stream

• Updates derived initialization to short-circuit the USB routing command when the base init has snapped 'InitializationPreservesActiveStream'. Prevents an observing session on USB from stealing a stream currently routed elsewhere (e.g., WiFi).

src/Daqifi.Core/Device/DaqifiStreamingDevice.cs

Tests (2) +86 / -0
DaqifiDeviceFactoryTests.csAdd coverage for PreserveActiveStream default and Observing preset +23/-0

Add coverage for PreserveActiveStream default and Observing preset

• Asserts that 'PreserveActiveStream' defaults to false to preserve historical behavior. Adds tests verifying 'DeviceConnectionOptions.Observing' enables stream preservation and that existing presets ('Fast', 'Resilient') do not.

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

DaqifiDeviceInitializeTests.csVerify observing initialization skips stream-disruptive commands +63/-0

Verify observing initialization skips stream-disruptive commands

• Adds tests ensuring initialization omits stop/power/format commands when 'PreserveActiveStream' is true while still disabling echo and querying identity. Confirms the device still reaches 'Ready' with channels populated and that USB routing is not attempted in observing mode.

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

Documentation (2) +73 / -1
README.mdWarn that connecting stops active streams; point to Observing and registry +7/-0

Warn that connecting stops active streams; point to Observing and registry

• Adds a prominent note that the default connect sequence takes control and can silently stop another session’s acquisition. Directs readers to 'DeviceConnectionOptions.Observing' for non-disruptive viewing and to 'DaqifiDeviceRegistry' for preventing duplicate opens in-process.

README.md

DEVICE_INTERFACES.mdDocument PreserveActiveStream/Observing and skipped init commands +66/-1

Document PreserveActiveStream/Observing and skipped init commands

• Extends connection options documentation with 'PreserveActiveStream' and the 'Observing' preset. Adds a dedicated section explaining why connecting can stop an existing stream, when to use observing mode, and a table enumerating which initialization commands are skipped.

docs/DEVICE_INTERFACES.md

@qodo-code-review

qodo-code-review Bot commented Jul 31, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Observer drops cancellation ✓ Resolved 🐞 Bug ☼ Reliability ⭐ New
Description
When PreserveActiveStream is true on a USB connection,
DaqifiStreamingDevice.OnDeviceInitializingAsync returns immediately without observing the
CancellationToken, so a token canceled late in InitializeAsync can be missed and the device can
still transition to Ready.
Code

src/Daqifi.Core/Device/DaqifiStreamingDevice.cs[R313-316]

+            if (preserveActiveStream)
+            {
+                return;
+            }
Relevance

●●● Strong

Team repeatedly adds late cancellation guards to prevent post-cancel state changes (e.g., PRs #249,
#381).

PR-#249
PR-#381

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new observer path returns from the USB derived init hook without checking cancellation, and the
base InitializeAsync does not perform another cancellation check before setting the device to Ready.

src/Daqifi.Core/Device/DaqifiStreamingDevice.cs[301-316]
src/Daqifi.Core/Device/DaqifiDevice.cs[1673-1676]

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

### Issue description
For USB connections in `DaqifiStreamingDevice.OnDeviceInitializingAsync`, the new `preserveActiveStream` early-return skips observing `cancellationToken`. If cancellation is requested during the final window of `DaqifiDevice.InitializeAsync` (after the last awaited operation but before readiness is committed), initialization may still complete and set the device to `Ready`.

### Issue Context
`DaqifiDevice.InitializeAsync` calls `OnDeviceInitializingAsync(...)` and then unconditionally marks the device initialized/ready if no exception is thrown. With the new observe-only path, the derived hook can return without throwing even when cancellation has been requested.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiStreamingDevice.cs[301-316]
- src/Daqifi.Core/Device/DaqifiDevice.cs[1673-1676]

### Suggested fix
Add a `cancellationToken.ThrowIfCancellationRequested();` before returning from the `preserveActiveStream` branch (and optionally also before the non-USB early return), or add a final cancellation check in `DaqifiDevice.InitializeAsync` immediately after awaiting `OnDeviceInitializingAsync` and before setting `_isInitialized`/`State = Ready`.

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


2. Init flag race ✓ Resolved 🐞 Bug ≡ Correctness
Description
InitializeAsync snapshots PreserveActiveStream into the shared InitializationPreservesActiveStream
field, which DaqifiStreamingDevice later reads to decide whether to route the stream to USB. If
InitializeAsync is called concurrently on the same device instance, the shared field can be
overwritten mid-initialization, causing an observing session to route the stream (stealing it) or a
controlling session to skip routing unexpectedly.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1581-1585]

+                // Snapshot once so every retry attempt — and the derived-class hook further down —
+                // sees the same decision even if a caller mutates the property mid-initialization.
+                var preserveActiveStream = PreserveActiveStream;
+                InitializationPreservesActiveStream = preserveActiveStream;
+
Relevance

●●● Strong

Team accepts concurrency/race hardening in device core (e.g., serialized ExecuteTextCommandAsync
#196; init timing/race fixes #249).

PR-#196
PR-#249
PR-#317

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
InitializeAsync writes the decision into an instance field and later calls the derived
initialization hook; the streaming device’s hook branches on that same instance field to decide
whether to send the USB routing command. Without serializing InitializeAsync, concurrent calls can
overwrite that field between snapshot and derived-hook execution, leading to the wrong routing
behavior.

src/Daqifi.Core/Device/DaqifiDevice.cs[1533-1563]
src/Daqifi.Core/Device/DaqifiDevice.cs[1581-1585]
src/Daqifi.Core/Device/DaqifiDevice.cs[1673-1678]
src/Daqifi.Core/Device/DaqifiStreamingDevice.cs[297-311]

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

### Issue description
`InitializationPreservesActiveStream` is a per-instance field set at the start of `InitializeAsync` and later read by `DaqifiStreamingDevice.OnDeviceInitializingAsync`. Because `InitializeAsync` is not serialized, two concurrent `InitializeAsync` calls on the same device can interleave and overwrite the field, leading the derived USB routing step to apply the wrong behavior (potentially disturbing an active stream even though the observing path was requested).

### Issue Context
This PR introduces the `InitializationPreservesActiveStream` field specifically so derived classes can respect the decision made for the current initialization. That decision needs to be operation-scoped, but the current implementation stores it in shared mutable state without guarding against concurrent initialization.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[1533-1694]
- src/Daqifi.Core/Device/DaqifiStreamingDevice.cs[297-311]

### Suggested fix
- Add a per-device async lock (e.g., `private readonly SemaphoreSlim _initializeLock = new(1, 1);`) in `DaqifiDevice`.
- Wrap the *entire* `InitializeAsync` body (from initial validation through `_isInitialized = true`) in `await _initializeLock.WaitAsync(cancellationToken)` / `finally { _initializeLock.Release(); }`.
- Keep the snapshot-to-local `preserveActiveStream` as-is, but set `InitializationPreservesActiveStream` only while holding the initialization lock so the derived hook cannot observe another operation’s value.

This both fixes the new flag race and prevents other pre-existing `InitializeAsync` reentrancy hazards (double subscriptions, competing waits, etc.).

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



Informational

3. Virtual hook API break 🐞 Bug ⚙ Maintainability
Description
DaqifiDevice replaces the protected virtual OnDeviceInitializingAsync(CancellationToken) hook
with OnDeviceInitializingAsync(bool, CancellationToken), breaking source compatibility for any
downstream subclasses that override the old hook. Existing binaries may also fail to load or may no
longer have their override invoked, causing subclass initialization logic to be skipped.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1713-1715]

+        protected virtual Task OnDeviceInitializingAsync(
+            bool preserveActiveStream,
+            CancellationToken cancellationToken) => Task.CompletedTask;
Relevance

● Weak

Similar “avoid breaking change” suggestions were rejected in PRs #329/#388; team accepts intentional
override breaks (PR #406).

PR-#329
PR-#388
PR-#406

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
DaqifiDevice is public, and the only remaining virtual hook is the new
OnDeviceInitializingAsync(bool, CancellationToken); InitializeAsync calls only that new
signature. This changes the subclass contract and requires downstream overrides to update or they
will no longer be compatible/reached.

src/Daqifi.Core/Device/DaqifiDevice.cs[22-29]
src/Daqifi.Core/Device/DaqifiDevice.cs[1673-1675]
src/Daqifi.Core/Device/DaqifiDevice.cs[1704-1715]
src/Daqifi.Core/Device/DaqifiStreamingDevice.cs[301-316]

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

## Issue description
A protected virtual extensibility hook was signature-changed (from `OnDeviceInitializingAsync(CancellationToken)` to `OnDeviceInitializingAsync(bool preserveActiveStream, CancellationToken)`), which is a breaking change for any consumer subclass that overrode the old method.

## Issue Context
`DaqifiDevice` is a public class and `OnDeviceInitializingAsync` is part of its supported subclassing surface. `InitializeAsync` now only calls the new signature, so old overrides won’t be reached.

## Fix
Re-introduce the previous hook signature as an overload and make the new overload delegate to the old one by default, e.g.:

```csharp
protected virtual Task OnDeviceInitializingAsync(CancellationToken cancellationToken)
   => Task.CompletedTask;

protected virtual Task OnDeviceInitializingAsync(
   bool preserveActiveStream,
   CancellationToken cancellationToken)
   => OnDeviceInitializingAsync(cancellationToken);
```

Keep `InitializeAsync` calling the new overload so new implementations can use `preserveActiveStream`, while old consumer overrides continue to run.

## Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[1673-1715]

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


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Previous review results

Review updated until commit 477dbc8

Results up to commit f8138bf ⚖️ Balanced


🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. Init flag race ✓ Resolved 🐞 Bug ≡ Correctness
Description
InitializeAsync snapshots PreserveActiveStream into the shared InitializationPreservesActiveStream
field, which DaqifiStreamingDevice later reads to decide whether to route the stream to USB. If
InitializeAsync is called concurrently on the same device instance, the shared field can be
overwritten mid-initialization, causing an observing session to route the stream (stealing it) or a
controlling session to skip routing unexpectedly.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1581-1585]

+                // Snapshot once so every retry attempt — and the derived-class hook further down —
+                // sees the same decision even if a caller mutates the property mid-initialization.
+                var preserveActiveStream = PreserveActiveStream;
+                InitializationPreservesActiveStream = preserveActiveStream;
+
Relevance

●●● Strong

Team accepts concurrency/race hardening in device core (e.g., serialized ExecuteTextCommandAsync
#196; init timing/race fixes #249).

PR-#196
PR-#249
PR-#317

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
InitializeAsync writes the decision into an instance field and later calls the derived
initialization hook; the streaming device’s hook branches on that same instance field to decide
whether to send the USB routing command. Without serializing InitializeAsync, concurrent calls can
overwrite that field between snapshot and derived-hook execution, leading to the wrong routing
behavior.

src/Daqifi.Core/Device/DaqifiDevice.cs[1533-1563]
src/Daqifi.Core/Device/DaqifiDevice.cs[1581-1585]
src/Daqifi.Core/Device/DaqifiDevice.cs[1673-1678]
src/Daqifi.Core/Device/DaqifiStreamingDevice.cs[297-311]

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

### Issue description
`InitializationPreservesActiveStream` is a per-instance field set at the start of `InitializeAsync` and later read by `DaqifiStreamingDevice.OnDeviceInitializingAsync`. Because `InitializeAsync` is not serialized, two concurrent `InitializeAsync` calls on the same device can interleave and overwrite the field, leading the derived USB routing step to apply the wrong behavior (potentially disturbing an active stream even though the observing path was requested).

### Issue Context
This PR introduces the `InitializationPreservesActiveStream` field specifically so derived classes can respect the decision made for the current initialization. That decision needs to be operation-scoped, but the current implementation stores it in shared mutable state without guarding against concurrent initialization.

### Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[1533-1694]
- src/Daqifi.Core/Device/DaqifiStreamingDevice.cs[297-311]

### Suggested fix
- Add a per-device async lock (e.g., `private readonly SemaphoreSlim _initializeLock = new(1, 1);`) in `DaqifiDevice`.
- Wrap the *entire* `InitializeAsync` body (from initial validation through `_isInitialized = true`) in `await _initializeLock.WaitAsync(cancellationToken)` / `finally { _initializeLock.Release(); }`.
- Keep the snapshot-to-local `preserveActiveStream` as-is, but set `InitializationPreservesActiveStream` only while holding the initialization lock so the derived hook cannot observe another operation’s value.

This both fixes the new flag race and prevents other pre-existing `InitializeAsync` reentrancy hazards (double subscriptions, competing waits, etc.).

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


Results up to commit 04c2514 ⚖️ Balanced


🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Informational
1. Virtual hook API break 🐞 Bug ⚙ Maintainability
Description
DaqifiDevice replaces the protected virtual OnDeviceInitializingAsync(CancellationToken) hook
with OnDeviceInitializingAsync(bool, CancellationToken), breaking source compatibility for any
downstream subclasses that override the old hook. Existing binaries may also fail to load or may no
longer have their override invoked, causing subclass initialization logic to be skipped.
Code

src/Daqifi.Core/Device/DaqifiDevice.cs[R1713-1715]

+        protected virtual Task OnDeviceInitializingAsync(
+            bool preserveActiveStream,
+            CancellationToken cancellationToken) => Task.CompletedTask;
Relevance

● Weak

Similar “avoid breaking change” suggestions were rejected in PRs #329/#388; team accepts intentional
override breaks (PR #406).

PR-#329
PR-#388
PR-#406

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
DaqifiDevice is public, and the only remaining virtual hook is the new
OnDeviceInitializingAsync(bool, CancellationToken); InitializeAsync calls only that new
signature. This changes the subclass contract and requires downstream overrides to update or they
will no longer be compatible/reached.

src/Daqifi.Core/Device/DaqifiDevice.cs[22-29]
src/Daqifi.Core/Device/DaqifiDevice.cs[1673-1675]
src/Daqifi.Core/Device/DaqifiDevice.cs[1704-1715]
src/Daqifi.Core/Device/DaqifiStreamingDevice.cs[301-316]

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

## Issue description
A protected virtual extensibility hook was signature-changed (from `OnDeviceInitializingAsync(CancellationToken)` to `OnDeviceInitializingAsync(bool preserveActiveStream, CancellationToken)`), which is a breaking change for any consumer subclass that overrode the old method.

## Issue Context
`DaqifiDevice` is a public class and `OnDeviceInitializingAsync` is part of its supported subclassing surface. `InitializeAsync` now only calls the new signature, so old overrides won’t be reached.

## Fix
Re-introduce the previous hook signature as an overload and make the new overload delegate to the old one by default, e.g.:

```csharp
protected virtual Task OnDeviceInitializingAsync(CancellationToken cancellationToken)
   => Task.CompletedTask;

protected virtual Task OnDeviceInitializingAsync(
   bool preserveActiveStream,
   CancellationToken cancellationToken)
   => OnDeviceInitializingAsync(cancellationToken);
```

Keep `InitializeAsync` calling the new overload so new implementations can use `preserveActiveStream`, while old consumer overrides continue to run.

## Fix Focus Areas
- src/Daqifi.Core/Device/DaqifiDevice.cs[1673-1715]

ⓘ 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
…storing it

Review caught a real race. The PreserveActiveStream decision was held in an
instance field between InitializeAsync capturing it and the derived hook
reading it, so a second InitializeAsync starting on the same device while the
first was still in flight overwrote it. The first initialization's USB routing
step then acted on the second one's decision — an observing session could route
the stream to itself, which is exactly the stream-stealing this change exists to
prevent.

Removes the field and passes the decision to OnDeviceInitializingAsync as a
parameter, so it lives on the stack and no concurrent operation can reach it.
Signature change to the protected hook; DaqifiStreamingDevice is the only
overrider.

Adds a regression test that overlaps two initializations on one device and
asserts each hook receives its own decision (fails as {false, false} against the
previous implementation), plus two tests pinning that the decision is read once
at the start and not re-read later.

No change to the SCPI sent on the wire for either path.
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 04c2514

@tylerkron

Copy link
Copy Markdown
Contributor Author

Re: finding 2, "Virtual hook API break" — declining, deliberately

The break is real and intended. I'm not taking the suggested fix, because it would reintroduce the exact defect this repo already decided against.

The suggested fix trades a loud failure for a silent one. Keeping the old hook alive and having the new overload delegate to it means a downstream subclass overriding the old signature keeps getting invoked — but never receives preserveActiveStream. So it would go on sending stream-disruptive commands during an observing initialization, silently defeating the entire feature this PR adds, with no compile error and no runtime signal. A subclass that quietly does the wrong thing is strictly worse than one that fails to compile with "add this parameter."

This is settled precedent here, and #406 is the direct case. That PR hit the identical fork and its body records the outcome:

The first version of this PR added a parallel ExecuteTextCommandWithPrepareAsync and claimed nothing broke. That was wrong, and Qodo caught it: the parallel method called the core directly, so a subclass overriding ExecuteTextCommandAsync silently stopped intercepting SD LIST and DELETE — no compile error, no runtime signal.

That break is the point. A compile error that says "add this parameter" is strictly better than an override that quietly stops intercepting, which is the defect class this whole series has been retiring.

#406 then broke ten in-repo overrides on purpose, plus any downstream ones. This change breaks one (DaqifiStreamingDevice). The parallel-hook shape being recommended here is the one #406 tried, got flagged for, and reverted.

For accuracy: I checked #329 and #388 as well and don't see anything in them supporting the "avoid breaking change was rejected" characterization — #406 is the precedent that actually carries this, and it carries it decisively.

On the binary-compatibility claim. "Existing binaries may fail to load" is technically right: a downstream assembly compiled against the old Core throws TypeLoadException on the stale override. That's a loud, immediate, unambiguous failure — which is the desired behavior, not a reason to avoid the change. "May no longer have their override invoked, causing subclass initialization logic to be skipped" is not what happens with this change; that silent-skip outcome is what the suggested fix would produce.

Scope of the blast radius: DaqifiStreamingDevice is the only overrider in this repo, daqifi-desktop wraps Core rather than subclassing DaqifiDevice, the fix is a one-line signature widening, and the library is 0.x. The PR body has a "One compile-time break" section covering this.

Also worth noting Qodo rates this one ● Weak relevance and badges it Optional/Informational, and its own Relevance note points at the precedent above.

No code change. Branch is unchanged at 04c2514, CI green (build pass, 1m35s), full suite green on net9.0 and net10.0 (2199 Core + 23 MCP).

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

Comment thread src/Daqifi.Core/Device/DaqifiStreamingDevice.cs
@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 04c2514

Review caught that an observing initialization could swallow a late
cancellation. After channels populate, nothing on that path is guaranteed to
observe the token: the capability read returns early on firmware that does not
advertise the document, channel population can short-circuit when the status
arrives synchronously, and the observing hook returns immediately because it has
no work to do. A caller that cancelled in that window got a successfully
completed task and a device reporting Ready.

Enforces the invariant in InitializeAsync, immediately before the transition to
Ready, rather than patching the one early return that was flagged. The same hole
exists on the pre-existing non-USB early return and in any third-party override
with no awaitable work; guarding the transition covers all of them.

Cancellation here follows the path already documented for this method: state
reverts to Connected, _isInitialized stays false, and the initialization can be
retried.

Adds a regression test that cancels at exactly that seam, covering both the
observing and take-control paths. It fails without the guard with "No exception
was thrown".

No change to the SCPI sent on the wire.
@tylerkron

Copy link
Copy Markdown
Contributor Author

Re: "Virtual hook API break" — this was answered in full here and it has been re-listed unchanged, with no new argument. The break is deliberate: PR #406 hit this exact fork, and the suggested delegating-overload shape is the one it tried, got flagged for, and reverted, because it leaves old overrides silently not receiving the new parameter.

Position unchanged. Treating this as a settled disagreement rather than relitigating it each round.

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 1049355

Resolves against #415 (connection-loss detection and the device ErrorOccurred
surface) and #417 (SD->LAN restore inside the exchange lock, which added a
finalizeAsync phase to ExecuteTextCommandAsync).

One real conflict, in DaqifiDeviceInitializeTests: #417 wrapped the testable
device's ExecuteTextCommandAsync body in try/finally to honor the new finalize
phase and re-indented it, while this branch had inserted a
MutateDuringInitialization hook between the prepare phase and setupAction.
Kept both — the hook now sits inside the new try block, still after prepare and
before setupAction, so the two overlapping-initialization tests still mutate
state at the intended point.

The two test doubles this branch added (OverlappingInitDevice and
CancelDuringCapabilityReadDevice) also had to widen their
ExecuteTextCommandAsync overrides for the finalizeAsync parameter, and now honor
the finalize phase the way the other doubles do. That compile break is the seam
from #406 working as designed.

Verified nothing from main was dropped: the only deletions relative to
origin/main are this branch's three intended OnDeviceInitializingAsync signature
changes. #415's ErrorOccurred wiring, OnConsumerErrorOccurred subscription and
the Connected->Lost transition, and #417's finalizeAsync phase are all intact,
as are this branch's PreserveActiveStream command skipping and the pre-Ready
cancellation guard.

Full suite green on net9.0 and net10.0 (2246 Core + 23 MCP).
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 477dbc8

@tylerkron
tylerkron merged commit 549a1b2 into main Jul 31, 2026
1 check passed
@tylerkron
tylerkron deleted the fix/second-connection-stops-stream branch July 31, 2026 22:38
tylerkron added a commit that referenced this pull request Jul 31, 2026
…nnect

#414 (opt-in connect that leaves a running stream alone) landed while the
previous merge was being resolved.

One conflict, in the docs' Advanced snippet, where #414 added a note about
InitializeAsync stopping a running stream to the same block this branch had
switched to the async/cancellable calls. Kept both: the async calls carry the
token, and #414's PreserveActiveStream guidance stays.

DaqifiDevice.cs and DaqifiDeviceFactory.cs auto-merged and were reviewed by
hand rather than trusted: #414 confined itself to InitializeAsync and a new
PreserveActiveStream property, which is disjoint from the connect/disconnect/
dispose restructuring here. In the factory, #414's PreserveActiveStream object
initializer still runs before the connect call, preserving its "never observed
half-applied" invariant now that the call is ConnectAsync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

Second connection to a streaming device silently stops the first session's stream (InitializeAsync sends StopStreaming unconditionally)

1 participant