Skip to content

fix(logging): a device that refuses a logging command ended the app — the disk-space hard stop, and the mode switch with a mixed USB/WiFi fleet - #218

Merged
tylerkron merged 3 commits into
mainfrom
fix/issue-214-device-refusal-crash
Sep 3, 2026
Merged

tylerkron merged 3 commits into
mainfrom
fix/issue-214-device-refusal-crash

Conversation

@tylerkron

@tylerkron tylerkron commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Not merging — for review.

closes #214

What the user saw

The application exited while they were logging. Not a wrong value, not a blank pane — the process ended, and everything not yet written to the database for that session was lost.

Two ways in, both reproduced here against origin/main @ cd44a11:

  1. The disk fills during a session. The monitor hits its 50 MB hard stop, DiskSpaceMonitorCoordinator.OnCriticalSpaceReached calls the host's StopLogging(), and that writes IsLogging = false. If any connected device's transport has gone, the app closes. The user's only warning about the full disk was the dialog that never got shown.
  2. One USB device, one WiFi device, click LOG TO DEVICE. DevicesPanePrototype.axaml:625 enables the radio from the selected device's IsUsb, but the setter applies the mode to every connected device, and SwitchMode refuses SD logging on a WiFi one. The app closes.

Root cause — one, not two

Both are the same defect: every logging command is issued from a UI property setter, in a loop with no failure handling. A throw out of a property setter reaches Dispatcher.UIThread.UnhandledException, and App.OnDispatcherUnhandledException (App.cs:351) only logs it — it never sets e.Handled — so the process dies. Exactly the ground #183 covered for the session row, one loop further down; LoggingManager.cs:188-197 already carries the note explaining why this class of throw is fatal here.

The escaping frames, measured, not inferred:

Daqifi.Core.Device.DeviceNotConnectedException: Device is not connected.
   at AbstractStreamingDevice.GetConnectedCoreDevice(...)   AbstractStreamingDevice.cs:1434
   at AbstractStreamingDevice.StopStreaming()               AbstractStreamingDevice.cs:1691
   at DaqifiViewModel.set_IsLogging(Boolean value)          DaqifiViewModel.cs:366     <-- escapes here

System.InvalidOperationException: SD Card logging is only available when connected via USB
   at AbstractStreamingDevice.SwitchMode(DeviceMode)        AbstractStreamingDevice.cs:1234
   at DaqifiViewModel.set_SelectedLoggingMode(String value) DaqifiViewModel.cs:535     <-- escapes here

Five device calls reach the dispatcher this way: InitializeStreaming, StopStreaming, StartSdCardLogging, StopSdCardLogging, and SwitchMode. In the mode-switch case the rollback has already put every device back before the throw; — so the rethrow's only remaining effect was to close the app.

A sixth, latent, on the same lines: both loops enumerated the live ObservableCollection<IStreamingDevice>. A command that surfaces as a lost connection tears its own device out of that collection through ConnectionManager.Disconnect, ending the enumeration with InvalidOperationException: Collection was modified — the same process death wearing a message about nothing. That reproduces too (see the test table).

Not a try/catch around the crash. The question a refusal actually asks is what it means for the rest of the fleet, and that decision now lives in a named place.

The fix

New Daqifi.Desktop.Device.LoggingFleet issues one command to every device, on a snapshot of the collection, and returns which devices accepted it and which refused and why. The two setters then decide:

what happens now
Start, some devices refuse The session continues. The devices that started are recording; taking the run away from them because a sibling declined would lose data the user asked for. The refusals are named in a dialog and a standing notification.
Start, every device refuses Nothing anywhere is recording, so the session is unwound and the toggle goes back to OFF — the same handling a failed session row already got in #183. Without this the header reads LOGGING ON over a plot that says no channels are streaming, which is reproduction 3 in the issue.
Start, no devices connected Unchanged: the toggle stays ON. An empty fleet is not a fleet that refused, and arming the toggle before connecting is ordinary use.
Stop, some devices refuse The local session always ends. A device that will not confirm a stop is usually one that is already gone, and refusing to end the session over it would leave the user with a toggle they cannot turn off. Reported, not obeyed.
Mode switch, any device refuses Still all-or-nothing, with the rollback it already had — SelectedLoggingMode is one value for the whole app and the UI cannot show half a fleet in each mode. What is new is surviving it: reported instead of rethrown, and the radio group is re-notified so it stops showing a mode nothing is in.

The mixed USB/WiFi fleet specifically: clicking LOG TO DEVICE with a WiFi device connected now leaves every device exactly where it was — streaming to the app — and tells the user which device blocked the switch and that SD logging needs USB. Nothing is left half-switched, and no device's session is interrupted. Whether the mode ought to be per-device instead is a design question, not a crash, and the issue explicitly scopes it out.

Refusal messages are Core's own ("Device is not connected.", "No SD card is installed in the device."), prefixed with the device's DeviceDisplayName, rather than a second copy of Core's taxonomy maintained here.

Reporting reuses the seam #183 built (ReportLoggingCouldNotStart), generalised to ReportLoggingProblem(title, message, isDefect). Device refusals log at Warning, not Error, so they do not file Sentry issues — a device declining a command is an environmental condition the app cannot prevent, the same rule SdCardFailureClassifier already applies. The #183 database path keeps its Error level.

What review added

Four more behaviours, all found by Qodo across two rounds, all real, and each pinned by a test that fails without it. Two were defects in the original crash fix; two were defects in the fix for those.

  • A refused SD stop must not hold the toggle ON. AbstractStreamingDevice.StopSdCardLogging clears IsLoggingToSdCard only after the device answers, and the IsLogging getter ORs _isLogging with that flag — so a refused stop reported success and the switch sprang straight back ON, which is the exact trap this change exists to avoid. A refused command leaves a stale flag, not evidence: the app stops asserting that device's SD state until it learns something new. The device-state fallback itself is kept — it is what makes the toggle tell the truth after reconnecting to a device that kept SD-logging across a desktop restart.
  • A rollback that itself fails is said out loud. A device that took the new mode and then refused to go back is stranded there, and LoggingFleet.Start picks each device's command from its actual Mode — so on the next start it would log to its SD card while the app showed "Stream to App", producing a run that never reaches the desktop and that the user has no reason to look for on the card. Stranded devices are now named, and the rollback carries on past one failure instead of abandoning the devices behind it.
  • The retry must not reinstate the stale flag. The doubt from a failed stop was originally cleared at the top of the next start — before any device had been asked anything. A retry the device also refuses then exposed the stale IsLoggingToSdCard again and the toggle sprang back ON, even as the all-refused unwind set _isLogging false. The bookkeeping now runs after the fan-out and is driven by what each device answered: accepted clears the doubt, refused keeps it.
  • A device that disconnects mid-stop must not stay suppressed. A stop can fail by disconnecting, which removes the wrapper from ConnectedDevices while the command is in flight — so the collection-changed cleanup had already run by the time the refusal was recorded, and the entry could never be cleaned up by anything. Only still-connected devices are recorded now, which also closes the leak.

Does Core already own this?

No — checked against the pinned 1.7.0 package (~/.nuget/packages/daqifi.core/1.7.0), not the sibling checkout's main. Core 1.7.0's device commands are throw-based (DeviceNotConnectedException, SdCardNotPresentException, SdCardBusyException, FeatureNotSupportedException, DeviceCommandFailedException) with no result-typed start/stop, and DaqifiDeviceRegistry is a discovery/registration registry, not a logging-fleet orchestrator. There is no fleet command fan-out in Core at all, so "what happens to the rest of the fleet when one device refuses" is genuinely this app's decision. No duplicated logic.

Evidence: the tests fail on main

Daqifi.Avalonia.Tests/ViewModels/DeviceRefusalCrashTests.cs, 16 tests. The 9 crash tests were run against unmodified DaqifiViewModel.cs from origin/main with the tests in place: 7 fail, 2 pass. Every one of the 7 fails by throwing out of the setter — the crash itself, not a changed assertion:

test on main
StoppingLogging_WhenTheDeviceRefuses_DoesNotEndTheApp DeviceNotConnectedException
StoppingLogging_WhenOneDeviceRefuses_StillStopsTheRestOfTheFleet DeviceNotConnectedException
StartingLogging_WhenOneDeviceRefuses_KeepsTheSessionForTheRest DeviceNotConnectedException
StartingLogging_WhenEveryDeviceRefuses_PutsTheToggleBackAndSaysSo DeviceNotConnectedException
StoppingLogging_WhenADeviceDisconnectsItselfMidCommand_DoesNotEndTheApp InvalidOperationException: Collection was modified
SwitchingToLogToDevice_WithAWifiDeviceInTheFleet_DoesNotEndTheApp InvalidOperationException: SD Card logging is only available when connected via USB
StartingSdCardLogging_WhenTheCardIsMissing_KeepsTheAppAndNamesTheReason SdCardNotPresentException
SwitchingToLogToDevice_WithAnAllUsbFleet_MovesTheWholeFleet passes — deliberate guard that the feature still works
StartingLogging_WithNoDeviceConnected_LeavesTheToggleOn passes — deliberate guard on the empty-fleet distinction

The 7 review tests were checked the same way — against this branch with only the production file(s) for that round stashed. All four review findings are pinned by a test that fails without its fix:

test without its fix
StoppingLogging_WhenAnSdCardStopIsRefused_StillLeavesTheToggleOff fails — toggle springs back ON
SwitchingMode_WhenTheRollbackAlsoFails_NamesTheDeviceItCouldNotPutBack fails — stranded device unreported
RetryingAfterARefusedSdStop_WhenTheRetryIsRefusedToo_LeavesTheToggleOff fails — toggle springs back ON
AStopThatFailsByDisconnecting_DoesNotStaySuppressedIfTheDeviceComesBack fails — state stays hidden after reconnect

Their three guards (ADeviceThatIsSdLoggingOnItsOwn_StillReportsTheSessionAsActive, SwitchingMode_WhenTheRollbackSucceeds_SaysNothingAboutStrandedDevices, RetryingAfterARefusedSdStop_WhenTheRetrySucceeds_ReportsTheSessionActiveAgain) pass either way — they exist so the fixes cannot quietly over-reach.

All 16 pass on this branch.

The devices that refuse in these tests are real AbstractStreamingDevices with no Core device behind them — the state a wrapper is in the moment a link drops — so the exceptions come from the production frames named above, not from a mock. Only the devices that are meant to succeed are stubs (RecordingStreamingDevice), because succeeding is the part a test has to fake: all four logging commands on AbstractStreamingDevice are non-virtual and all four go through GetConnectedCoreDevice, so a double either refuses everything or needs a live transport.

The test class calls App.InitializeMobile() — the IsLogging setter writes LoggingManager.Instance on its first line, and that resolves its context factory off App.ServiceProvider. That makes it a SQLite user, so it sits in a DisableParallelization collection to stay out of the suite's existing ClearAllPools race rather than adding to it. (Worth noting for future work: DaqifiViewModel is constructible in a test host, which the note in DeleteAllSessionsRecoveryTests says it is not. That note is stale.)

Bench — real hardware

Real Nq1 on /dev/cu.usbmodem1101 (serial 9090539562006014104, firmware 3.7.2), driven through the app's own SerialStreamingDevice and LoggingFleet, with a mixed fleet — and the refusing device placed FIRST, so on main the real board would never have been reached at all:

START succeeded=1 refused=1
  refusals: Nq1-GHOST: Device is not connected.
  real.IsStreaming = True          <-- the healthy board started anyway
STOP  succeeded=2 refused=0
  real.IsStreaming = False

That is the mixed-fleet claim confirmed on hardware. Non-destructive throughout — connect, start streaming, stop streaming, disconnect. No firmware or WiFi flash, no SD format or delete, no reboot, no live SD:GET. The refusal was provoked in software (a wrapper with no Core device), never by putting the board in a state it could not come back from. The throwaway harness was deleted afterwards.

What the user sees now

The standing notification, rendered by booting the real app headless through tools/parity-audit/run.sh and driving the actual refusal path — this is the app's own output, not a mock-up. The app is still running, the bell is badged, the toggle went back OFF, and the plot correctly reads "No channels streaming" instead of LOGGING ON over nothing:

The Notifications flyout open over the Live Graph, the bell badged 1, reading "Logging could not be started on any connected device. Nq1-2044: Device is not connected." while the plot area shows the No channels streaming empty state

The other new surface is a MessageBox dialog carrying the same sentence. It is not pictured: the capture harness constructs no dialogs, which is issue #213. The harness edit used to drive the notification path was reverted.

Gate

Measured in this worktree, resolved DLL path checked (.../agent-a82aad3313ae6754f/Daqifi.Avalonia.Tests/bin/Debug/net10.0/):

  • Desktop build ✅ · iOS Release/iossimulator-arm64 ✅ · Android not built — there is no Android head on this Mac, so no Android status is claimed; CI covers it
  • dotnet test 402/402 (baseline on origin/main measured at 386 in this worktree, +16 new)
  • First-party warnings 176 → 176, identical sets by diff after normalising line numbers, third_party/ excluded. LoggingFleet.cs contributes none.
  • No packages.lock.json drift.
  • Diff: +1257 / −51 across 4 files (2 production, 2 test).

Pre-existing, not mine: DeleteAllSessionsRecoveryTests.DeleteAll_RemovesTheRollbackJournalTheOldPurgeLeftBehind failed once on the origin/main baseline run with ObjectDisposedException: SQLitePCL.sqlite3 and passed in isolation — a cross-class SqliteConnection.ClearAllPools() race that predates this branch.

Scope

Both paths in #214 reproduce, and they are one root cause: every logging
command is issued from a UI property setter in a bare loop, and a throw out
of a property setter reaches Dispatcher.UIThread.UnhandledException, which
App.OnDispatcherUnhandledException only logs — it never sets Handled, so the
process dies mid-session.

The fan-out now runs through LoggingFleet, which commands every device,
collects the refusals, and lets the caller decide what they mean:

- start: the devices that did start keep the session; the refusals are
  reported. Only when EVERY device refuses is the session unwound, because
  only then is nothing anywhere recording.
- stop: the local session always ends; a device that will not confirm is
  reported, not obeyed.
- mode switch: still all-or-nothing with the rollback it already had, but
  reported instead of rethrown.

Also fixes the latent fifth failure the issue records: both loops walked the
live ConnectedDevices collection, so a command that surfaced as a lost
connection ended the enumeration with "Collection was modified".

closes #214

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

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Prevent device logging refusals from terminating the app

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Prevents device command refusals from escaping UI setters and terminating active logging sessions.
• Preserves partial starts, completes stops, and atomically rolls back failed fleet mode changes.
• Reports per-device failures and covers disconnected, mixed-transport, and collection-mutation
 regressions.
Diagram

graph TD
  UI["Logging setters"] --> Fleet["LoggingFleet"] --> Snapshot["Fleet snapshot"] --> Commands["Device commands"] --> Result["Fleet result"]
  Result -->|start or stop| Policy["Session policy"] --> Feedback["User feedback"]
  Result -->|mode refusal| Rollback["Mode rollback"] --> Feedback
Loading
High-Level Assessment

The centralized LoggingFleet abstraction is the preferred approach because it snapshots mutable collections, isolates each device command, and returns enough information for operation-specific policy. A global dispatcher exception handler would hide unrelated defects, while wrapping the original loops in a single try/catch would still stop fan-out after the first refusal and could not distinguish partial success from total failure.

Files changed (4) +911 / -54

Bug fix (2) +370 / -54
LoggingFleet.csCentralize failure-tolerant fleet logging commands +262/-0

Centralize failure-tolerant fleet logging commands

• Adds snapshot-based start and stop fan-out that continues after per-device failures and returns aggregated outcomes. Mode switching remains atomic, restoring original modes after refusal while containing and logging rollback failures.

Daqifi.Avalonia/Daqifi.Desktop/Device/LoggingFleet.cs

DaqifiViewModel.csHandle fleet command outcomes without dispatcher crashes +108/-54

Handle fleet command outcomes without dispatcher crashes

• Routes logging and mode operations through LoggingFleet, preserving partial starts, unwinding all-refused starts, completing local stops, and restoring failed mode selections. Generalizes logging problem reporting to emit contextual warnings, persistent notifications, and dialogs without rethrowing device refusals.

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs

Tests (2) +541 / -0
RecordingStreamingDevice.csAdd a controllable logging-command test device +196/-0

Add a controllable logging-command test device

• Introduces an IStreamingDevice test double that records accepted commands, injects command-specific refusals, and can mutate connection state during fan-out. It enables assertions about command ordering and whether healthy devices are reached after a sibling fails.

Daqifi.Avalonia.Tests/Device/RecordingStreamingDevice.cs

DeviceRefusalCrashTests.csCover device refusals and mutable-fleet logging regressions +345/-0

Cover device refusals and mutable-fleet logging regressions

• Adds end-to-end view-model tests for failed starts and stops, partial fleet success, empty fleets, self-removing devices, mixed USB/WiFi mode changes, rollback behavior, and missing SD cards. The tests also verify user-visible refusal details and local session state.

Daqifi.Avalonia.Tests/ViewModels/DeviceRefusalCrashTests.cs

@qodo-code-review

qodo-code-review Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Failed retry reactivates toggle ✓ Resolved 🐞 Bug ≡ Correctness ⭐ New
Description
A new start clears every failed-stop suppression before knowing whether devices accepted the start,
so an SD device that refuses again exposes its stale IsLoggingToSdCard = true. Even after the
all-refused path sets _isLogging false, the getter therefore reports logging as active and the
toggle returns to ON.
Code

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[R353-355]

+                // A new start supersedes whatever the last failed stop left unknown: every device
+                // is about to be commanded again, so its answer is the current one.
+                _sdStateUnknownAfterFailedStop.Clear();
Relevance

●●● Strong

Retry clears suppression before outcome, exposing stale SD state when every device refuses; this is
a deterministic toggle-state bug.

PR-#176

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
IsLogging ORs _isLogging with device SD state, and the new predicate ignores stale state only
while the device remains in _sdStateUnknownAfterFailedStop. StartSdCardLogging updates the flag
only after success and leaves it unchanged when it throws, so clearing suppression before
LoggingFleet.Start makes an all-refused retry expose the prior true flag despite the unwind
setting _isLogging false.

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[298-300]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[353-368]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[426-462]
Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1313-1324]
Daqifi.Avalonia.Tests/ViewModels/DeviceRefusalCrashTests.cs[348-356]

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

## Issue description
Do not clear failed-stop SD-state suppression before issuing start commands. If a device refuses the retry, its unchanged `IsLoggingToSdCard` value remains stale and must not force the logging toggle back on.

## Issue Context
`LoggingFleet.Start` identifies which devices succeeded. Remove suppression only for those successful devices; retain it for devices whose new start was refused, including the all-devices-refused path.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[353-374]
- Daqifi.Avalonia.Tests/ViewModels/DeviceRefusalCrashTests.cs[322-358]

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


2. Failed SD stop stays active ✓ Resolved 🐞 Bug ≡ Correctness
Description
When StopSdCardLogging throws, LoggingFleet.Stop records the refusal without clearing
IsLoggingToSdCard, but the updated stop path still declares the local session ended. Because the
IsLogging getter ORs _isLogging with that unchanged device flag, the final notification restores
the toggle to ON and can leave the user unable to complete the stop.
Code

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[R387-392]

+                var stop = LoggingFleet.Stop(ConnectedDevices, _appLogger);
+
+                // The local session has ended regardless — see LoggingFleet.Stop. A device that
+                // would not stop is reported, not obeyed: refusing to end the session over it
+                // would leave the user with a toggle that cannot be turned off.
+                if (stop.AnyRefused)
Relevance

●●● Strong

Recent precedents favor propagating failed cleanup state; unchanged device flags can contradict the
local session state.

PR-#178
PR-#200

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
IsLogging returns true whenever any connected device still reports SD logging.
AbstractStreamingDevice.StopSdCardLogging clears that flag only after the device call succeeds,
while the new fleet code catches a thrown stop and the changed setter proceeds as though the local
session has ended.

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[298-326]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[383-402]
Daqifi.Avalonia/Daqifi.Desktop/Device/LoggingFleet.cs[121-132]
Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1328-1351]

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 refused SD-card stop leaves `IsLoggingToSdCard` true, causing the `IsLogging` getter to report true even though the new stop path ends the local session. Preserve remote-device status separately while ensuring an explicit local stop leaves the session toggle off.

## Issue Context
`StopSdCardLogging` only clears its state after a successful device response. The new fleet handling catches failures and continues, so the getter's device-state fallback now conflicts with the stated stop policy.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[298-326]
- Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[383-402]
- Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1328-1351]

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



Remediation recommended

3. Disconnect leaves stale suppression ✓ Resolved 🐞 Bug ≡ Correctness ⭐ New
Description
If a stop command removes its device before throwing, collection cleanup runs before the refusal
loop and the loop then re-adds the now-disconnected wrapper to _sdStateUnknownAfterFailedStop.
Reconnecting that same wrapper preserves the entry, causing its newly reported SD-logging state to
remain hidden.
Code

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[R403-405]

+                    foreach (var refusal in stop.Refusals)
                    {
-                        device.StopSdCardLogging();
+                        _sdStateUnknownAfterFailedStop.Add(refusal.Device);
Relevance

●●● Strong

Disconnect cleanup can race refusal bookkeeping, leaving identity-based suppression attached across
reconnect; stale-state cleanup is an established accepted concern.

PR-#55

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
LoggingFleet.Stop retains the exact wrapper in the refusal result. Connection teardown removes
that wrapper and invokes the new RemoveWhere, but the setter processes the returned refusal
afterward and adds it back; ConnectionManager.Connect can later add the same supplied instance,
while the predicate suppresses every true flag from an identity still in the set.

Daqifi.Avalonia/Daqifi.Desktop/Device/LoggingFleet.cs[265-279]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[391-405]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[461-462]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[1811-1817]
Daqifi.Avalonia/Daqifi.Desktop/ConnectionManager.cs[527-537]
Daqifi.Avalonia/Daqifi.Desktop/ConnectionManager.cs[443-478]

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

## Issue description
Prevent a stop refusal from adding a device to the unknown-state set after that device has already left `ConnectedDevices`. Otherwise the stale identity survives when the same wrapper reconnects and suppresses its fresh SD state.

## Issue Context
A device command can synchronously trigger connection teardown before throwing. Filter refusal devices using reference membership in the current connected set, and ensure disconnect cleanup cannot be undone by the later refusal-processing loop.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[398-405]
- Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[1813-1818]
- Daqifi.Avalonia.Tests/ViewModels/DeviceRefusalCrashTests.cs[322-429]

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


4. Failed rollback reported successful ✓ Resolved 🐞 Bug ≡ Correctness
Description
If restoring a device's original mode throws, SwitchMode only logs the exception and still returns
an empty success list with just the initial refusal, causing the setter to claim the fleet was
restored. The application can therefore continue showing the old mode while a device remains in the
new mode, and a later start command can log using the unintended destination.
Code

Daqifi.Avalonia/Daqifi.Desktop/Device/LoggingFleet.cs[R201-205]

+            catch (Exception rollbackException)
+            {
+                appLogger.Warning(
+                    $"Failed to roll back logging mode for {Describe(device)}: {rollbackException.Message}");
+            }
Relevance

●●● Strong

Recent precedents accept reporting failed rollback cleanup instead of silently claiming successful
restoration.

PR-#178
PR-#200

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rollback calls the same throwable SwitchMode operation but catches and discards its failure before
returning. The caller interprets any refusal result as a completed rollback, leaves
_selectedLoggingMode unchanged, and tells the user the mode was left at that value; meanwhile
LoggingFleet.Start chooses each device command from its actual Mode, so a stranded device can
later log to a different destination than the UI indicates.

Daqifi.Avalonia/Daqifi.Desktop/Device/LoggingFleet.cs[190-208]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[562-574]
Daqifi.Avalonia/Daqifi.Desktop/Device/LoggingFleet.cs[98-109]
Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1230-1268]

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 failed mode rollback is swallowed, after which the caller reports that the fleet was restored even when a device remains in the requested mode. Represent rollback failures in the result and prevent the UI from claiming a uniform old mode until the fleet has actually been reconciled.

## Issue Context
Mode restoration itself performs transport-backed operations and can throw if a connection disappears. Since the PR now keeps the process alive after this condition, it must explicitly handle the resulting mixed-mode fleet.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Device/LoggingFleet.cs[190-208]
- Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[562-574]
- Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1230-1268]

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


Grey Divider

Context sources
Review mode: ⚖️ Balanced: This is a behavior-changing fix to logging state and disconnect handling with subtle ordering and user-visible consequences, but the logic remains localized enough for one careful review pass.

Grey Divider

Tip of the day
💡 Did you know, you can add REVIEW.md to your repo root and Qodo follows it on every PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 076e27c

Results up to commit c54b78a ⚖️ Balanced


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


Action required
1. Failed SD stop stays active ✓ Resolved 🐞 Bug ≡ Correctness
Description
When StopSdCardLogging throws, LoggingFleet.Stop records the refusal without clearing
IsLoggingToSdCard, but the updated stop path still declares the local session ended. Because the
IsLogging getter ORs _isLogging with that unchanged device flag, the final notification restores
the toggle to ON and can leave the user unable to complete the stop.
Code

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[R387-392]

+                var stop = LoggingFleet.Stop(ConnectedDevices, _appLogger);
+
+                // The local session has ended regardless — see LoggingFleet.Stop. A device that
+                // would not stop is reported, not obeyed: refusing to end the session over it
+                // would leave the user with a toggle that cannot be turned off.
+                if (stop.AnyRefused)
Relevance

●●● Strong

Recent precedents favor propagating failed cleanup state; unchanged device flags can contradict the
local session state.

PR-#178
PR-#200

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
IsLogging returns true whenever any connected device still reports SD logging.
AbstractStreamingDevice.StopSdCardLogging clears that flag only after the device call succeeds,
while the new fleet code catches a thrown stop and the changed setter proceeds as though the local
session has ended.

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[298-326]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[383-402]
Daqifi.Avalonia/Daqifi.Desktop/Device/LoggingFleet.cs[121-132]
Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1328-1351]

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 refused SD-card stop leaves `IsLoggingToSdCard` true, causing the `IsLogging` getter to report true even though the new stop path ends the local session. Preserve remote-device status separately while ensuring an explicit local stop leaves the session toggle off.

## Issue Context
`StopSdCardLogging` only clears its state after a successful device response. The new fleet handling catches failures and continues, so the getter's device-state fallback now conflicts with the stated stop policy.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[298-326]
- Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[383-402]
- Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1328-1351]

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



Remediation recommended
2. Failed rollback reported successful ✓ Resolved 🐞 Bug ≡ Correctness
Description
If restoring a device's original mode throws, SwitchMode only logs the exception and still returns
an empty success list with just the initial refusal, causing the setter to claim the fleet was
restored. The application can therefore continue showing the old mode while a device remains in the
new mode, and a later start command can log using the unintended destination.
Code

Daqifi.Avalonia/Daqifi.Desktop/Device/LoggingFleet.cs[R201-205]

+            catch (Exception rollbackException)
+            {
+                appLogger.Warning(
+                    $"Failed to roll back logging mode for {Describe(device)}: {rollbackException.Message}");
+            }
Relevance

●●● Strong

Recent precedents accept reporting failed rollback cleanup instead of silently claiming successful
restoration.

PR-#178
PR-#200

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rollback calls the same throwable SwitchMode operation but catches and discards its failure before
returning. The caller interprets any refusal result as a completed rollback, leaves
_selectedLoggingMode unchanged, and tells the user the mode was left at that value; meanwhile
LoggingFleet.Start chooses each device command from its actual Mode, so a stranded device can
later log to a different destination than the UI indicates.

Daqifi.Avalonia/Daqifi.Desktop/Device/LoggingFleet.cs[190-208]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[562-574]
Daqifi.Avalonia/Daqifi.Desktop/Device/LoggingFleet.cs[98-109]
Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1230-1268]

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 failed mode rollback is swallowed, after which the caller reports that the fleet was restored even when a device remains in the requested mode. Represent rollback failures in the result and prevent the UI from claiming a uniform old mode until the fleet has actually been reconciled.

## Issue Context
Mode restoration itself performs transport-backed operations and can throw if a connection disappears. Since the PR now keeps the process alive after this condition, it must explicitly handle the resulting mixed-mode fleet.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Device/LoggingFleet.cs[190-208]
- Daqifi.Avalonia/Daqifi.Desktop/ViewModels/DaqifiViewModel.cs[562-574]
- Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1230-1268]

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


Grey Divider

Qodo Logo

…d rollback must be said

Qodo round 1, both conceded and both reproduced as failing tests first.

1. Failed SD stop stays active (High). AbstractStreamingDevice.StopSdCardLogging
   clears IsLoggingToSdCard only after the device answers, and the IsLogging
   getter ORs _isLogging with that flag — so a refused stop reported success and
   the toggle sprang straight back ON. The user turns logging off, is told a
   device did not confirm, and watches the switch refuse to move: exactly the
   trap this change exists to avoid. A refused command leaves a STALE flag, not
   evidence, so the app stops asserting that device's SD state until it learns
   something new. Cleared on the next start and when the device disconnects.

2. Failed rollback reported successful (Medium). If restoring a device's original
   mode threw, SwitchMode logged it and still reported the fleet restored — while
   the device stayed in the abandoned mode. LoggingFleet.Start picks each device's
   command from its actual Mode, so that device would log to its SD card on the
   next start while the app showed "Stream to App". Stranded devices are now
   collected and named, and the rollback carries on past one failure rather than
   abandoning the devices behind it.

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

@tylerkron

Copy link
Copy Markdown
Contributor Author

After state, rendered from the real app (see the PR body's What the user sees now).

The Notifications flyout open over the Live Graph, the bell badged 1, reading "Logging could not be started on any connected device. Nq1-2044: Device is not connected." while the plot area shows the No channels streaming empty state

…ways

Qodo round 2, both conceded, both reproduced as failing tests first. Both are
defects in round 1's own fix, not in the original crash.

1. Failed retry reactivates toggle. The doubt left by a refused stop was cleared
   at the TOP of the next start, before any device had been asked anything — so
   a retry the device also refuses reinstated the stale IsLoggingToSdCard, the
   getter reported logging active, and the toggle sprang back ON even as the
   all-refused unwind set _isLogging false. The bookkeeping now runs AFTER the
   fan-out and is driven by what each device answered: accepted clears the doubt,
   refused keeps it.

2. Disconnect leaves stale suppression. A stop can fail BY disconnecting, which
   removes the wrapper from ConnectedDevices mid-fan-out — so the collection-
   changed cleanup had already run by the time the refusal was recorded, and the
   entry could never be cleaned up by anything. It did nothing while the device
   was gone and would have suppressed that same wrapper's freshly reported state
   if it reconnected. ForgetSdState now records only still-connected devices.

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

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.

A device that refuses a logging command ends the app: the disk-space hard stop, and the mode switch with a mixed USB/WiFi fleet

1 participant