Skip to content

feat(discovery): AllTransportsDeviceFinder + DiscoverAndConnectAsync (closes #338) - #354

Merged
tylerkron merged 3 commits into
mainfrom
feat/all-transports-discovery
Jul 19, 2026
Merged

tylerkron merged 3 commits into
mainfrom
feat/all-transports-discovery

Conversation

@tylerkron

Copy link
Copy Markdown
Contributor

Summary

Turns the README's "find any DAQiFi on WiFi or USB" promise into a real one-call API (issue #338), deleting the instantiate-both / run-both / concatenate / dedupe fan-out that the MCP server, desktop, and example CLI otherwise each re-implement.

Changes

  • AllTransportsDeviceFinder : IDeviceFinder — fans DiscoverAsync out across N transport finders (WiFi + serial today, mDNS/Add MDnsDeviceFinder for reliable discovery on home/multi-AP networks (broadcast UDP is fragile) #183 later) concurrently and returns one deduplicated set. A transport that throws (e.g. WiFi discovery with no network) is logged and skipped so the others still return. Dedup reuses ContinuousDeviceFinder.DefaultIdentity (feat: Continuous/stateful discovery (live deduplicated device set + DeviceLost) #245) — the same unit on two transports stays two connection options; pass a custom identitySelector (e.g. by serial) to collapse them. Because it is an IDeviceFinder, wrapping it in ContinuousDeviceFinder gives deduplicated continuous discovery for free (covers the one-shot + continuous acceptance criterion).
  • DaqifiDeviceFactory.DiscoverAndConnectAsync(filter?, timeout?, options?, finder?, ct?) — the common "find one and connect" case in a single call; thin wrapper over the aggregator + existing ConnectFromDeviceInfoAsync.
  • Docs (DEVICE_INTERFACES.md) — new "Discover Across All Transports (Recommended)" section; the per-transport manual merge is reframed as the advanced path.

Testing

  • dotnet test — 1653 passed / 0 failed / 2 skipped (net9.0 + net10.0).
  • 18 new unit tests: fan-out union, dedup (first-wins), same-device-two-transports kept as two, custom identity selector collapses, empty-selector fallback, one-transport-throws resilience, DeviceDiscovered/DiscoveryCompleted events, cancellation propagation, TimeSpan overload, dispose-ownership (caller-supplied finders not disposed), post-dispose throws, constructor validation, and DiscoverAndConnectAsync selection paths (no-device / filter-no-match / pre-cancelled throw).
  • Bench-validated on Nq1 (FW 3.7.2): AllTransportsDeviceFinder.CreateDefault().DiscoverAsync(3s) found the device over serial while WiFi returned empty without error; DiscoverAndConnectAsync(filter: serial) connected (IsConnected=True) and disconnected cleanly.

Acceptance criteria (#338)

  • One call discovers across WiFi + serial concurrently, deduplicated
  • Works for one-shot and continuous (wrap in ContinuousDeviceFinder)
  • DiscoverAndConnectAsync with optional filter/timeout returns a connected device
  • Docs updated — per-transport merge is now the advanced path

Not merging — for review.

🤖 Generated with Claude Code

@tylerkron
tylerkron requested a review from a team as a code owner July 18, 2026 23:59
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

…loses #338)

"Find any DAQiFi on WiFi or USB" becomes one call instead of the manual
instantiate-both / run-both / concatenate / dedupe dance every consumer repeats.

- AllTransportsDeviceFinder : IDeviceFinder fans DiscoverAsync out across N transport
  finders concurrently and returns one deduplicated set. A failing transport (e.g. WiFi
  with no network) is logged and skipped so the others still return. Dedup reuses
  ContinuousDeviceFinder.DefaultIdentity (#245); a custom identitySelector can collapse
  the same unit across transports. Being an IDeviceFinder, wrapping it in
  ContinuousDeviceFinder yields deduplicated continuous discovery for free.
- DaqifiDeviceFactory.DiscoverAndConnectAsync(filter?, timeout?, options?, finder?) —
  the common "find one and connect" case in a single call.
- Docs: the per-transport merge is now the advanced path; the aggregator is the default.
- 18 unit tests (fan-out, dedup, per-transport vs custom identity, transport-failure
  resilience, events, cancellation, dispose ownership, factory selection paths).

Bench-validated on Nq1 FW 3.7.2: aggregated discovery found the device over serial (WiFi
skipped gracefully); DiscoverAndConnectAsync connected and disconnected cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tylerkron
tylerkron force-pushed the feat/all-transports-discovery branch from 81683ab to 1fc2ff0 Compare July 18, 2026 23:59
@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

feat(discovery): add AllTransportsDeviceFinder for multi-transport discovery

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Add an IDeviceFinder aggregator that discovers across multiple transports concurrently.
• Deduplicate aggregated results with a configurable identity (default per-transport key).
• Add unit tests for union, dedup semantics, resilience, events, cancellation, and disposal.
Diagram

graph TD
  A["Consumer"] --> B["AllTransports finder"] --> E["Dedup + events"] --> F["Unified device list"]
  B --> C["WiFi finder"]
  B --> D["Serial finder"]
  E --> G["DefaultIdentity"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Stream results as transports complete (Task.WhenAny)
  • ➕ Lower perceived latency: DeviceDiscovered can fire as soon as any transport returns
  • ➕ More responsive UX for UIs during slow network discovery
  • ➖ More complex coordination (completion semantics, deterministic ordering)
  • ➖ Harder to preserve strict 'first finder wins' behavior
2. Forward inner DeviceDiscovered events and dedup in real time
  • ➕ Can surface devices before DiscoverAsync completes
  • ➕ Potentially reuses per-transport event behavior
  • ➖ Requires careful subscription management and teardown
  • ➖ More edge cases around duplicate event emissions and completion coordination
3. Use ILogger abstraction instead of Trace for transport failures
  • ➕ Integrates with host logging and structured logging sinks
  • ➕ Easier to test/capture logs and control severity
  • ➖ Adds a logging dependency or requires plumbed optional logger across discovery APIs

Recommendation: The current approach (batch fan-out via Task.WhenAll + deterministic finder-ordered 'first wins' dedup) is a good default for a one-shot API and keeps behavior predictable. Consider a streaming completion strategy only if discovery latency becomes a practical issue for UI consumers.

Files changed (2) +474 / -0

Enhancement (1) +190 / -0
AllTransportsDeviceFinder.csIntroduce multi-transport discovery aggregator with dedup/events +190/-0

Introduce multi-transport discovery aggregator with dedup/events

• Adds AllTransportsDeviceFinder implementing IDeviceFinder that runs multiple transport finders concurrently, merges results, and deduplicates via an optional identitySelector (falling back to ContinuousDeviceFinder.DefaultIdentity). Non-cancellation exceptions from a single transport are traced and skipped so other transports can still return results; supports a timeout overload and ownership-based disposal via CreateDefault().

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs

Tests (1) +284 / -0
AllTransportsDeviceFinderTests.csAdd tests for fan-out union, dedup rules, resilience, events, and lifecycle +284/-0

Add tests for fan-out union, dedup rules, resilience, events, and lifecycle

• Adds unit tests validating union across finders, per-transport default identity dedup behavior, custom identity selector behavior (including empty-key fallback), resilience when one finder throws, correct DeviceDiscovered/DiscoveryCompleted signaling, cancellation/timeout handling, and disposal/constructor validation.

src/Daqifi.Core.Tests/Device/Discovery/AllTransportsDeviceFinderTests.cs

@qodo-code-review

qodo-code-review Bot commented Jul 19, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 📜 Skill insights (0)

Context used

Grey Divider


Remediation recommended

1. Trace can break isolation ✓ Resolved 🐞 Bug ☼ Reliability ⭐ New
Description
RaiseIsolated catches subscriber exceptions but then calls Trace.WriteLine inside the catch; if a
configured TraceListener throws, that exception can still escape and abort discovery despite the
intended isolation. This makes discovery outcomes depend on logging infrastructure correctness in an
error path.
Code

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[R145-155]

+        private static void RaiseIsolated(Action raise, string eventName)
+        {
+            try
+            {
+                raise();
+            }
+            catch (Exception ex)
+            {
+                // Discovery outcome must not depend on consumer callback correctness.
+                Trace.WriteLine($"[{nameof(AllTransportsDeviceFinder)}] {eventName} subscriber threw: {ex}");
+            }
Relevance

⭐⭐⭐ High

Team previously hardened against logging throwing in background paths (SafeLog wrapper) to prevent
crashes (PR #260).

PR-#260
PR-#180

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The catch block that is supposed to isolate subscriber exceptions performs an unguarded
Trace.WriteLine, which can itself throw and therefore violate the isolation intent.

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[145-155]

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

## Issue description
`AllTransportsDeviceFinder.RaiseIsolated` is intended to ensure event subscriber exceptions cannot affect discovery. However, the catch block logs via `Trace.WriteLine(...)` without guarding that call; if a `TraceListener` throws, the exception can still bubble out and break the isolation guarantee.

## Issue Context
The code explicitly states: “Discovery outcome must not depend on consumer callback correctness.” That should also apply to best-effort logging in this error path.

## Fix Focus Areas
- src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[145-155]

## Suggested fix
Wrap the `Trace.WriteLine(...)` in its own best-effort try/catch (or otherwise ensure the logging path cannot throw), e.g.:
- `catch (Exception ex) { try { Trace.WriteLine(...); } catch { } }`

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


2. Timeout can throw cancellation ✓ Resolved 🐞 Bug ≡ Correctness
Description
AllTransportsDeviceFinder.DiscoverAsync(TimeSpan) implements timeout by canceling a token and
routing through the cancellation-token overload; because SafeDiscoverAsync rethrows
OperationCanceledException, a timeout can propagate as an OperationCanceledException (and skip
DiscoveryCompleted) when any child finder observes that cancellation. This differs from
WiFiDeviceFinder/SerialDeviceFinder TimeSpan overloads, which treat timeout as a normal end-of-pass
and return whatever was found.
Code

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[R119-137]

+        /// <inheritdoc />
+        public async Task<IEnumerable<IDeviceInfo>> DiscoverAsync(TimeSpan timeout)
+        {
+            using var cts = new CancellationTokenSource(timeout);
+            return await DiscoverAsync(cts.Token).ConfigureAwait(false);
+        }
+
+        private async Task<IEnumerable<IDeviceInfo>> SafeDiscoverAsync(IDeviceFinder finder, CancellationToken cancellationToken)
+        {
+            try
+            {
+                return await finder.DiscoverAsync(cancellationToken).ConfigureAwait(false)
+                    ?? Enumerable.Empty<IDeviceInfo>();
+            }
+            catch (OperationCanceledException)
+            {
+                // Caller-requested cancellation must surface, not be swallowed as a transport failure.
+                throw;
+            }
Relevance

⭐⭐⭐ High

Repo treats timeout-cancellation as normal completion (e.g., catch OCE on timeout; continuous
discovery pass timeout not error).

PR-#330
PR-#248

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The TimeSpan overload is implemented by canceling a token and calling into code that explicitly
rethrows OperationCanceledException, so timeout can become a cancellation exception. In contrast,
existing WiFi/Serial finders implement their timeout overloads to return results without throwing
when the timeout elapses.

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[119-137]
src/Daqifi.Core/Device/Discovery/WiFiDeviceFinder.cs[241-342]
src/Daqifi.Core/Device/Discovery/SerialDeviceFinder.cs[274-321]
src/Daqifi.Core/Device/Discovery/IDeviceFinder.cs[23-36]

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

### Issue description
`AllTransportsDeviceFinder.DiscoverAsync(TimeSpan)` uses a `CancellationTokenSource(timeout)` and calls `DiscoverAsync(cts.Token)`. If any underlying finder throws `OperationCanceledException` when the timeout token is canceled, `Task.WhenAll` will propagate the cancellation and `DiscoveryCompleted` won’t fire.

### Issue Context
Other transport finders’ `DiscoverAsync(TimeSpan)` overloads are written to return a device set on timeout (not throw) by treating the timeout as a normal completion condition.

### Fix Focus Areas
- src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[119-144]

### Suggested fix
Pick one consistent strategy:
1) Implement `DiscoverAsync(TimeSpan timeout)` by fanning out to each transport’s `DiscoverAsync(timeout)` overload (not the cancellation-token overload) and treat any `OperationCanceledException` as a timeout for that transport (return empty for that transport).

OR
2) Keep the cancellation-token approach, but catch `OperationCanceledException` in the TimeSpan overload (timeout-originated only) and return the union of tasks that completed successfully before timeout (similar to how SerialDeviceFinder drains completed probes on cancellation).

In both approaches, ensure `DiscoveryCompleted` still fires for a timeout completion path.

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


3. Event handler faults discovery ✓ Resolved 🐞 Bug ☼ Reliability
Description
AllTransportsDeviceFinder invokes DeviceDiscovered/DiscoveryCompleted directly, so a throwing
subscriber can fail DiscoverAsync, stop subsequent DeviceDiscovered notifications, and prevent
DiscoveryCompleted from firing. This makes discovery outcomes dependent on consumer callback
correctness rather than transport behavior.
Code

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[R106-116]

+            foreach (var device in results.SelectMany(r => r))
+            {
+                if (device != null && seen.Add(Identity(device)))
+                {
+                    unique.Add(device);
+                    DeviceDiscovered?.Invoke(this, new DeviceDiscoveredEventArgs(device));
+                }
+            }
+
+            DiscoveryCompleted?.Invoke(this, EventArgs.Empty);
+            return unique;
Relevance

⭐⭐⭐ High

Team previously accepted swallowing DeviceDiscovered subscriber exceptions to prevent discovery
fault/hang (WiFiDeviceFinder fix).

PR-#180

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The aggregator currently calls event delegates directly (exception-propagating). Existing WiFi
discovery code explicitly swallows subscriber exceptions to prevent discovery tasks from faulting
due to consumer code; this establishes the expected resilience pattern in this codebase.

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[104-116]
src/Daqifi.Core/Device/Discovery/WiFiDeviceFinder.cs[381-411]
PR-#180

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

### Issue description
`AllTransportsDeviceFinder` raises `DeviceDiscovered` and `DiscoveryCompleted` without guarding subscriber exceptions. Any exception thrown by a consumer callback will bubble out of `DiscoverAsync`, potentially aborting the dedup loop and skipping `DiscoveryCompleted`.

### Issue Context
Other discovery components already treat subscriber exceptions as non-fatal (so discovery can’t be broken by UI/event code).

### Fix Focus Areas
- src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[104-116]

### Suggested fix
- Wrap `DeviceDiscovered?.Invoke(...)` and `DiscoveryCompleted?.Invoke(...)` in `try/catch` blocks (optionally `Trace.WriteLine` the exception).
- Consider ensuring `DiscoveryCompleted` is raised in a `finally` (for non-caller-cancellation paths) so it’s not skipped if a `DeviceDiscovered` handler throws.

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



Informational

4. Misleading timeout failure log ✓ Resolved 🐞 Bug ◔ Observability ⭐ New
Description
SafeDiscoverAsync(IDeviceFinder, TimeSpan) catches all exception types but logs them as “timed-out
discovery failed,” which mischaracterizes non-timeout transport failures during debugging. This
reduces diagnostic accuracy without changing functional behavior.
Code

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[R185-190]

+            catch (Exception ex)
+            {
+                // Timeout is a normal end-of-pass here (no caller token to honor); a finder that
+                // fails or throws on its own timeout must not sink the other transports' results.
+                Trace.WriteLine($"[{nameof(AllTransportsDeviceFinder)}] {finder.GetType().Name} timed-out discovery failed: {ex}");
+                return Enumerable.Empty<IDeviceInfo>();
Relevance

⭐⭐⭐ High

Team values accurate failure classification/messages (e.g., timeout vs cancel) and changed
behavior/logging accordingly (PR #237).

PR-#237

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code logs a timeout-specific phrase inside a broad catch (Exception ex) block, so the log text
does not reliably represent the root cause of the caught exception.

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[178-193]

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

## Issue description
`SafeDiscoverAsync(IDeviceFinder, TimeSpan)` catches any `Exception` and logs it using wording that implies a timeout (“timed-out discovery failed”). Since the catch is not limited to timeout-related exceptions, the log message is misleading for other failures.

## Issue Context
This method is used by the TimeSpan overload, but exceptions can be caused by many reasons besides a timeout (configuration, socket failures, disposed resources, etc.).

## Fix Focus Areas
- src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[178-193]

## Suggested fix
Change the message to be neutral (e.g., “discovery failed during timeout-based discover”) and/or include the `timeout` value. Optionally, special-case `OperationCanceledException` if you want timeout-specific wording only for that case.

ⓘ 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 66cd9d9

Results up to commit 1fc2ff0


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


Remediation recommended
1. Timeout can throw cancellation ✓ Resolved 🐞 Bug ≡ Correctness
Description
AllTransportsDeviceFinder.DiscoverAsync(TimeSpan) implements timeout by canceling a token and
routing through the cancellation-token overload; because SafeDiscoverAsync rethrows
OperationCanceledException, a timeout can propagate as an OperationCanceledException (and skip
DiscoveryCompleted) when any child finder observes that cancellation. This differs from
WiFiDeviceFinder/SerialDeviceFinder TimeSpan overloads, which treat timeout as a normal end-of-pass
and return whatever was found.
Code

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[R119-137]

+        /// <inheritdoc />
+        public async Task<IEnumerable<IDeviceInfo>> DiscoverAsync(TimeSpan timeout)
+        {
+            using var cts = new CancellationTokenSource(timeout);
+            return await DiscoverAsync(cts.Token).ConfigureAwait(false);
+        }
+
+        private async Task<IEnumerable<IDeviceInfo>> SafeDiscoverAsync(IDeviceFinder finder, CancellationToken cancellationToken)
+        {
+            try
+            {
+                return await finder.DiscoverAsync(cancellationToken).ConfigureAwait(false)
+                    ?? Enumerable.Empty<IDeviceInfo>();
+            }
+            catch (OperationCanceledException)
+            {
+                // Caller-requested cancellation must surface, not be swallowed as a transport failure.
+                throw;
+            }
Relevance

⭐⭐⭐ High

Repo treats timeout-cancellation as normal completion (e.g., catch OCE on timeout; continuous
discovery pass timeout not error).

PR-#330
PR-#248

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The TimeSpan overload is implemented by canceling a token and calling into code that explicitly
rethrows OperationCanceledException, so timeout can become a cancellation exception. In contrast,
existing WiFi/Serial finders implement their timeout overloads to return results without throwing
when the timeout elapses.

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[119-137]
src/Daqifi.Core/Device/Discovery/WiFiDeviceFinder.cs[241-342]
src/Daqifi.Core/Device/Discovery/SerialDeviceFinder.cs[274-321]
src/Daqifi.Core/Device/Discovery/IDeviceFinder.cs[23-36]

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

### Issue description
`AllTransportsDeviceFinder.DiscoverAsync(TimeSpan)` uses a `CancellationTokenSource(timeout)` and calls `DiscoverAsync(cts.Token)`. If any underlying finder throws `OperationCanceledException` when the timeout token is canceled, `Task.WhenAll` will propagate the cancellation and `DiscoveryCompleted` won’t fire.

### Issue Context
Other transport finders’ `DiscoverAsync(TimeSpan)` overloads are written to return a device set on timeout (not throw) by treating the timeout as a normal completion condition.

### Fix Focus Areas
- src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[119-144]

### Suggested fix
Pick one consistent strategy:
1) Implement `DiscoverAsync(TimeSpan timeout)` by fanning out to each transport’s `DiscoverAsync(timeout)` overload (not the cancellation-token overload) and treat any `OperationCanceledException` as a timeout for that transport (return empty for that transport).

OR
2) Keep the cancellation-token approach, but catch `OperationCanceledException` in the TimeSpan overload (timeout-originated only) and return the union of tasks that completed successfully before timeout (similar to how SerialDeviceFinder drains completed probes on cancellation).

In both approaches, ensure `DiscoveryCompleted` still fires for a timeout completion path.

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


2. Event handler faults discovery ✓ Resolved 🐞 Bug ☼ Reliability
Description
AllTransportsDeviceFinder invokes DeviceDiscovered/DiscoveryCompleted directly, so a throwing
subscriber can fail DiscoverAsync, stop subsequent DeviceDiscovered notifications, and prevent
DiscoveryCompleted from firing. This makes discovery outcomes dependent on consumer callback
correctness rather than transport behavior.
Code

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[R106-116]

+            foreach (var device in results.SelectMany(r => r))
+            {
+                if (device != null && seen.Add(Identity(device)))
+                {
+                    unique.Add(device);
+                    DeviceDiscovered?.Invoke(this, new DeviceDiscoveredEventArgs(device));
+                }
+            }
+
+            DiscoveryCompleted?.Invoke(this, EventArgs.Empty);
+            return unique;
Relevance

⭐⭐⭐ High

Team previously accepted swallowing DeviceDiscovered subscriber exceptions to prevent discovery
fault/hang (WiFiDeviceFinder fix).

PR-#180

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The aggregator currently calls event delegates directly (exception-propagating). Existing WiFi
discovery code explicitly swallows subscriber exceptions to prevent discovery tasks from faulting
due to consumer code; this establishes the expected resilience pattern in this codebase.

src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[104-116]
src/Daqifi.Core/Device/Discovery/WiFiDeviceFinder.cs[381-411]
PR-#180

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

### Issue description
`AllTransportsDeviceFinder` raises `DeviceDiscovered` and `DiscoveryCompleted` without guarding subscriber exceptions. Any exception thrown by a consumer callback will bubble out of `DiscoverAsync`, potentially aborting the dedup loop and skipping `DiscoveryCompleted`.

### Issue Context
Other discovery components already treat subscriber exceptions as non-fatal (so discovery can’t be broken by UI/event code).

### Fix Focus Areas
- src/Daqifi.Core/Device/Discovery/AllTransportsDeviceFinder.cs[104-116]

### Suggested fix
- Wrap `DeviceDiscovered?.Invoke(...)` and `DiscoveryCompleted?.Invoke(...)` in `try/catch` blocks (optionally `Trace.WriteLine` the exception).
- Consider ensuring `DiscoveryCompleted` is raised in a `finally` (for non-caller-cancellation paths) so it’s not skipped if a `DeviceDiscovered` handler throws.

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


Qodo Logo

…meout non-throwing

Addresses two Qodo findings on #354:
- Isolate DeviceDiscovered/DiscoveryCompleted subscriber exceptions (RaiseIsolated) so a
  throwing consumer callback can't abort the dedup loop or skip DiscoveryCompleted.
- DiscoverAsync(TimeSpan) now delegates to each finder's own timeout overload instead of
  routing through a cancellation token, so a timeout is a normal end-of-pass (returns partial
  results, fires DiscoveryCompleted) rather than propagating OperationCanceledException —
  matching WiFiDeviceFinder/SerialDeviceFinder. Adds regression tests for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Both fixed in dfba82a:

  1. Event handler faults discovery — DeviceDiscovered/DiscoveryCompleted are now raised via RaiseIsolated (try/catch + Trace.WriteLine), so a throwing subscriber can't abort the dedup loop or skip DiscoveryCompleted. Test: DiscoverAsync_ThrowingDeviceDiscoveredSubscriber_StillReturnsAllAndCompletes.
  2. Timeout can throw cancellation — DiscoverAsync(TimeSpan) now delegates to each finder's own DiscoverAsync(TimeSpan) overload (a separate non-rethrowing SafeDiscoverAsync), so a timeout is a normal end-of-pass returning partial results + firing DiscoveryCompleted, matching WiFiDeviceFinder/SerialDeviceFinder — no OperationCanceledException. Test: DiscoverAsync_Timeout_FinderThrowsCancellation_ReturnsOthersAndCompletes.

Full suite 1655 pass / 0 fail (net9+net10).

@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 dfba82a

…ssage

Addresses two Qodo findings on #354:
- Add SafeTrace (Trace.WriteLine wrapped in try/catch) and route all three of the class's
  log sites through it, so a throwing TraceListener can't escape the event-isolation /
  transport-failure catch blocks and break discovery.
- Reword the TimeSpan-overload failure log so it no longer implies "timeout" for what may be
  any transport failure (socket error, disposed resource, etc.).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tylerkron

Copy link
Copy Markdown
Contributor Author

Both fixed in the latest commit:

  1. Trace can break isolation — added SafeTrace (Trace.WriteLine wrapped in try/catch) and routed all three log sites through it, so a throwing TraceListener can't escape the isolation/failure catch blocks. (No dedicated test: manipulating the static Trace.Listeners under xUnit's parallel runner would risk corrupting sibling tests; the guard is an empty-catch swallow.)
  2. Misleading timeout log — the TimeSpan-overload failure message no longer implies a timeout ("discovery failed during a timed pass"), since the catch covers any transport failure.

Full suite 1655 pass / 0 fail (net9+net10).

@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 66cd9d9

@tylerkron

Copy link
Copy Markdown
Contributor Author

Latest Qodo pass clean (0 unresolved threads, updated to 66cd9d9), CI green, full suite 1655 pass, bench-validated on Nq1. Bench-verified, ready for review. Not merging — leaving for you.

@tylerkron
tylerkron merged commit abbee54 into main Jul 19, 2026
1 check passed
@tylerkron
tylerkron deleted the feat/all-transports-discovery branch July 19, 2026 14:29
tylerkron added a commit that referenced this pull request Jul 31, 2026
…the reader loop

Addresses Qodo round 3 on #415. ReportStreamFault invoked ITransportHealthSink
and ErrorOccurred without isolation, so a throwing callback escaped the reader
thread — process-fatal, the same shape as the escaping-catch defect fixed last
round, one layer up. Concentrating the four fault sites into one helper made a
single unguarded callback affect all of them at once.

The route is two hops, and the crash trace confirms it exactly: the handler
throws on the fault path, the loop's outer catch reports that failure by
calling the same handler, and the second throw is inside a catch block with
nothing above it. Verified against pre-fix code — the test host dies with the
unhandled exception surfacing from ProcessMessages' outer catch.

Every callback out of the loop is now isolated via SafeReportIoFault /
SafeReportIoSuccess / SafeRaiseError: the two ReportStreamFault callbacks, the
per-read success report, the outer catch's error raise, and the error raise in
ProcessMessageBuffer's dispatch handler. Plain methods rather than a lambda
helper so the once-per-read success path allocates no closure. Swallowed rather
than logged, matching the convention already used for a throwing
MessageReceived subscriber (#180) and mirrored across RaiseClassifiedEvent
(#323), AllTransportsDeviceFinder (#354), DeviceFinderBase and
RaiseGapDetected.

Two regression tests assert the reader keeps consuming — a real message
delivered after the throwing phase — not merely that nothing surfaced. Both
verified failing pre-fix: the subscriber case crashes the host, the health-sink
case silently stops delivering messages.

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.

1 participant