Repository navigation
fix(devices): a dropped WiFi device is never torn down, and Core's background failures are invisible - #172
Conversation
…'s background failures A WiFi/TCP device that dropped stayed in ConnectedDevices forever. Nothing in the desktop shell subscribed to the drop, because there was nothing to subscribe to: Core's ConnectionStatus.Lost only cleared IsStreaming and wrote one warning. The one teardown path, CheckIfSerialDeviceWasRemoved, filters ConnectedDevices.OfType<SerialStreamingDevice>() — serial only. Per drop, permanently for the process: the wrapper, its Core device and that device's transport (for WiFi, an open socket) stay reachable from a static singleton, because the only code that disposes them is Disconnect, which nothing was calling. Its channels stay in LoggingManager marked active, still counting toward CanToggleLogging and still listed as live inputs. Adds IDevice.ConnectionLost, raised by AbstractStreamingDevice on Core's Lost and Failed (not Disconnected — at Core 1.7.0 that is only ever a caller-issued teardown, whose handler is already detached), and a ConnectionManager handler that releases the channels, disconnects, and notifies naming the device and the reason. The IDeviceWatcher stays: the two paths are redundant, not conflicting, and both now converge on one TearDownDroppedDevice. Also ports IDevice.ErrorOccurred / SendFailed, which Core 1.7.0 raises and this app never consumed, so a dead read loop, a failed decode, an exhausted reconnect and a dropped SCPI command stop being indistinguishable from silence. DescribeCommand logs the verb only, which is what keeps the WiFi password out of the log. IsAppBug routes StatusNotification to Error rather than inheriting upstream's Core-1.4.0 default — every StatusChanged subscriber is this app's own code. Closes #167 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The test constructor left DuplicateDeviceHandler unassigned, which is a CS8618 — the same one the singleton constructor already carries. Assign it explicitly to the state the singleton is in until the connection dialog installs one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoTear down dropped devices and surface Core failures
AI Description
Diagram
High-Level Assessment
Files changed (11)
|
…down TearDownDroppedDevice runs inside an action posted to the dispatcher, so an exception copying DataChannels would take the whole teardown with it and leave the device connected — the exact state this fixes — with nothing to catch it. DataChannels is a plain List that Core's background events rebuild wholesale, which is the same hazard ActiveChannelCount already guards. Snapshot it behind a catch and let the disconnect proceed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Code Review by Qodo
1.
|
…eady dropped Two windows Qodo found, both opened or widened by this PR's own teardown. Between device.Connect() returning and SubscribeDeviceEvents wiring the loss handler, a drop is unobservable — there is no subscriber, and even with one the handler would find the device not yet in ConnectedDevices and return. So subscribing earlier does not close it; re-reading the device's own IsConnected does. Accepting a wrapper whose transport is already gone would put exactly the stale entry into ConnectedDevices that this PR exists to remove. The rejected device is disconnected rather than left holding its transport handle. During Connect's one-second post-connect settle the handler IS live, and it runs on the UI thread while the continuation is suspended. Without a guard the continuation resumed and published Connected plus SetDeviceContext for hardware that had just been torn down. Both now report Error, which is what every other failure in this method reports and what keeps the connection dialog open. Bench (Nq1 fw 3.7.2, USB, 5 connect/disconnect cycles): the accept-guard's predicate read true immediately after Connect() on all 5, so it rejects no healthy connect; 0 ConnectionLost, 0 ErrorOccurred, 0 SendFailed throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 27d020e |
Qodo round 2. Snapshotting DataChannels behind a catch handles the throw but not the quieter half of the same race: Core rebuilds that list with a Clear followed by an AddRange, so a read landing in between comes back short — or empty — without throwing anything. Every channel missed that way stays in LoggingManager marked active for the process lifetime, still counting toward CanToggleLogging. That is the leak this PR exists to close, reintroduced through a race. The logger's own SubscribedChannels has no such window: it is published copy-on-write, so a reader always gets a complete immutable list. Teardown now filters it by the device's serial number, ordinally, which is the same discriminator LoggingManager.Unsubscribe resolves by. A device with no serial cannot be matched that way — a blank serial would sweep up every other unidentified device's channels — so that case alone keeps the device's own list. Test: Channels_are_released_even_if_the_device_list_is_being_rebuilt clears DataChannels to reproduce the interval exactly, and fails against the previous implementation. A_device_with_no_serial_number_falls_back_to_its_own_channel_list pins the fallback, including that it does not touch an identified device's channels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
…st commit It still said channels are released before Disconnect because Disconnect clears DataChannels — true when the lookup read DataChannels, but the lookup now reads the logger. The ordering still matters, for the no-serial-number fallback only, so say that instead. The matching test is renamed and widened to assert both halves of what teardown must leave behind: nothing subscribed, nothing connected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Code review by qodo was updated up to the latest commit 4894820 |
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit deddf3b |
# Conflicts: # Daqifi.Avalonia/Daqifi.Avalonia.csproj
Not merging — for review. Closes #167.
What accumulates, and what the user hits
A device connected over WiFi/TCP that drops — router reboot, AP roam, cable to the AP pulled, a silently dead socket — is never torn down in the desktop shell. Nothing subscribed to the drop, because there was nothing to subscribe to: Core reports
ConnectionStatus.Lost, and the app's entire response was to clearIsStreamingand write one warning (AbstractStreamingDevice.cs:603→:623). The only teardown path,CheckIfSerialDeviceWasRemoved, filtersConnectedDevices.OfType<SerialStreamingDevice>()— serial only, by construction.So per drop, and permanently for the process:
ConnectionManager.ConnectedDevices— a static singleton. Through it, its Core device and that device's transport stay reachable. The only code that disposes them isAbstractStreamingDevice.CleanupConnection, reached only fromConnect()andDisconnect(), and nothing was callingDisconnect(). For WiFi that is an open socket per drop.LoggingManager.SubscribedChannelswithIsActive = trueand theirOnChannelUpdatedhandler still attached.DaqifiViewModelrebuildsActiveChannelsfrom that list, andCanToggleLogging = ActiveChannels.Count > 0— so the app still believes it can start a logging session, and still lists the dead device's inputs as live.CheckForDuplicateDevicematches the stale entry on serial number and the user gets the duplicate-device prompt.What the user sees first is the frozen sample counter, with no notification and no route back except disconnecting by hand. The mobile shell was already fine (
MobileShellViewModel:710); this was desktop-only.Gap B, same
IDevicesurface: Core 1.7.0 raisesErrorOccurredandSendFailedand this app consumed neither (SendFailedhad zero grep hits in the repo). A dead read loop, a failed decode, an exhausted reconnect and a SCPI command that never reached the device were all indistinguishable from a quiet device.The premise was verified before implementing, not assumed. #167 came from a source-level audit; I re-derived it and it holds.
IsConnectedhas three consumers —DeviceTileViewModelre-renders a border,DeviceLogsViewModelgreys out SD buttons,MobileShellViewModeltears down — and none of the three is the desktop shell's device lifetime.The fix
IDevice.ConnectionLost+ConnectionLostEventArgs, raised byAbstractStreamingDevice.OnCoreStatusChanged.ConnectionManager.OnDeviceConnectionLostreleases the channels, callsDisconnect(device)(which disposes the Core device and its transport), and setsLastDisconnectReasonbefore raisingNotifyConnection, so the dialog names the device and the reason instead of saying "Device disconnected unexpectedly."SubscribeDeviceEvents/UnsubscribeDeviceEvents, mirrored, wired atConnectand at both teardowns (Disconnect,Reboot).AbstractStreamingDevice.Diagnostics.csforwards Core'sErrorOccurred/SendFailedwiththisas sender, so a log line names the device the way the user sees it.Connect(see Review below): a device whose transport is already gone whenConnect()returns is refused rather than added, and a drop during the one-second post-connect settle aborts the connect instead of completing it.The
IDeviceWatcherstays. This port's macOS/Linux USB hotplug detection lives there and is strictly better than upstream's pre-deletion state. The two paths are redundant, not conflicting: both skip a device under firmware update, both now converge on oneTearDownDroppedDevice, and whichever arrives second finds the device already gone and returns. The serial path also now names the device in its notification, which it did not before.Four places this deliberately does not copy upstream
Disconnectedis not treated as a drop. Upstream's set isLost | Failed | Disconnected. At Core 1.7.0Disconnectedis produced only byDisconnectCoreandMarkDisconnectedWithoutTeardown, both under a caller-issued teardown — and every app-initiated teardown here detaches the handler before touching the Core device. Acting on it could only re-enter teardown on a device already being torn down.Failedis handled although unreachable today (Core only reports it when auto-reconnect exhausts, and this app leavesReconnectOptionsat its disabled default), so enabling reconnect later cannot silently reintroduce the untorn-down device.IsAppBug(StatusNotification) => true. Upstream enumerates the Core 1.4.0 set, where that member did not exist, and its default arm routes it to Warning. Core 1.7.0 raises it for exactly one thing: aStatusChangedsubscriber that threw. Every subscriber to that event is this app's own code, and the usual cause is a cross-thread mutation of bound state from Core's transport thread — a real defect, on the drop path, where it is hardest to notice. fix(devices): a dropped WiFi device is never torn down, and Core's background failures are invisible #167 asked for this to be a decision rather than a default; it is, and a test fails if Core adds a sixth member so the next one is a decision too.RebindDiagnosticshas no upstream counterpart. Upstream binds the Core subscription lazily on first subscriber only. HereConnect()is a template that can run twice on one wrapper, so the bridge is re-pointed on connect and released inCleanupConnection— otherwise a wrapper with a live subscriber holds the Core device cleanup just disposed, which is the same leak shape this PR is about.ReconcileDeviceAfterUpdateis untouched. It still does teardown only. Its existing comment explains why raisingNotifyConnectionthere would pop a second, contradictory dialog over the firmware coordinator's own; addingLastDisconnectReasondoes not change that.Mobile gets the two diagnostics events via
RegisterConnectedDevicebut deliberately notConnectionLost:MobileShellViewModel.HandleConnectionLostalready tears down and does more than this class can (it returns the shell to its device-list state). Wiring both would be two teardowns racing one drop for no gain.Review
Qodo found four across two rounds, all accepted and fixed. Three of them are windows this PR's own teardown opened, which is worth stating plainly:
Connect's one-second post-connect settle completed the connect anyway. The loss handler goes live before that delay and runs on the UI thread while the continuation is suspended, so the device could be torn down and the continuation would still publishConnectedandSetDeviceContextfor hardware that was gone. Unreachable before this PR, because nothing tore down. Now guarded.device.Connect()returning and the handler being wired was unobservable — and the dead wrapper was accepted intoConnectedDevicesanyway, recreating the exact stale entry this PR removes. Subscribing earlier does not fix it (the handler early-returns on!ConnectedDevices.Contains(device)); re-reading the device's ownIsConnectedat the accept point does. The refused device isDisconnect()ed so its transport handle is released rather than leaked.DataChannelsis a plainListCore rebuilds wholesale, andTearDownDroppedDeviceruns inside an action posted to the dispatcher — so a throw there would take the whole teardown with it and leave the device connected, with nothing to catch it. (I had found and fixed this one independently before the review landed.)Clearand itsAddRangecomes back short, or empty, without throwing anything, and every channel missed that way stays inLoggingManageractive for the process lifetime. So teardown no longer asks the device which channels are its own; it asks the logger, whoseSubscribedChannelsis published copy-on-write and therefore cannot be observed mid-rebuild, filtering by serial number ordinally — the same discriminatorLoggingManager.Unsubscriberesolves by. That is also strictly more complete: it catches a subscription left over from an earlier session of the same device, which the device's own list would no longer mention. A device that never reported a serial cannot be matched that way (a blank serial would sweep up every other unidentified device's channels), so that case alone keeps the device's own list.Verification
Tests: 27 → 65. 38 new, all green (
dotnet test), and confirmed green on CI's Linux job.The teardown suite is the regression evidence. With
OnDeviceConnectionLostneutered to reproducemain's behaviour, 8 of its 10 cases fail — removed-from-list, channels-released, channels-released-before-the-device-clears-them, reason-names-the-device, only-the-dropped-device, subscribed-twice, second-report-is-a-no-op, and the one that goes throughConnectionManager.Connect()itself. Each assertion is literally the state the app was left in. The two that still pass are the ones asserting nothing happens (firmware-update exemption, explicitly-disconnected device unwired) — they pass vacuously againstmainand are guards on the fix, not proof of the bug.ConnectionManagergets aninternaltest constructor replacing exactly three things that make the singleton unconstructible in a test host: its real serial-poller watcher,LoggingManager.Instance(whose ctor needsApp.ServiceProvider), and the UI marshal (Avalonia's dispatcher binds to one thread and queues onto it forever when nothing pumps). Teardown, the firmware carve-out, the subscription bookkeeping and the notification are all production code.A_drop_while_the_connection_is_settling_aborts_the_connectis the one timing-dependent case: it fires the drop from a background task that waits until the device is inConnectedDevices, then pauses 50 ms — ~50 ms into a 1000 ms window. Both failure directions are red rather than green (too early: nothing is wired, so no teardown; too late: the device is still in the list), and it held over 8 consecutive runs.Channels_are_released_even_if_the_device_list_is_being_rebuiltclearsDataChannelsto reproduce Core's rebuild interval exactly. It fails against the previous implementation — verified by reverting the lookup: 64 passed, 1 failed.DescribeCommandis pinned directly on the case it exists for:SYSTem:COMMunicate:LAN:PASs "hunter2"logs asSYSTem:COMMunicate:LAN:PASs, asserted to not contain the password.Bench (real Nq1, fw 3.7.2, USB,
/dev/cu.usbmodem1101). The physical unplug that producesLostneeds a hand on the cable, so it was not reproduced. What the bench covers is the false-positive direction — the risk this change introduces. A headless harness driving the app's ownSerialStreamingDevicepath, with all three events subscribed beforeConnect()(soRebindDiagnostics's connect-side attach is the code under test), over 5 connect/disconnect cycles:The first line is the one that matters for guard #2 above: a false rejection there would stop the app connecting at all, and
IsConnectedread true immediately afterConnect()on 5 of 5.ErrorOccurred/SendFailedwere attached across real sessions with real device traffic (the metadata above came back over the wire) and raised zero times — no failure was induced, so the handlers' log formatting and severity are covered by the unit tests, not by the bench. Board left disconnected with nothing enabled and nothing streaming; board lock held for each run and released.Build gate. Measured on the merge-base and on this head with the same cleared-
objdiscipline and the identical command, because the count moves with build state:iossimulator-arm64dotnet testFlat on both heads, 0 errors. (A first pass added one CS8618 from the test constructor; fixed rather than shipped.)
git status --short -- '**/packages.lock.json'is empty — restore was run without-r. The Android head does not build on this Mac (no workload); CI covers it.Collision check
Diffed #159, #161, #163, #164 and #170 first. #170 is the only adjacency: it edits
AbstractStreamingDevice.Connect()(adds aHydrateDeviceMetadatacall afterOnCoreDeviceInitialized()) andHydrateDeviceMetadataitself. This PR'sConnect()edit is one line ~10 lines earlier, right afterCoreDevice = coreDevice;— textually adjacent, semantically independent. #165 (channel enablement) was left alone. No.portomatic/map/edit: neither new file has a map row today, so nothing there is made stale, and three open PRs are editingUI.yaml.Sizing
git diff --numstat origin/main...HEAD: 1677 insertions, 14 deletions across 11 files — 797 lines in 4 new test files, 880 insertions / 14 deletions in 7 app files.Noticed, deliberately not included
ConnectionManager.Disconnectand the post-connect duplicate-rejection path both drop a device without disposing it, so each leaks its transport handle — upstream fixed this with(device as IDisposable)?.Dispose(). It is the same class of bug, but disposing there changes the lifetime of wrappers the connection dialog holds and reconnects, which I cannot validate from here. Worth its own issue rather than a rider on this one.🤖 Generated with Claude Code