Skip to content

fix(plot): the live sample counter under-reported by 5x — count what the device delivers - #153

Merged
tylerkron merged 5 commits into
mainfrom
fix/120-live-sample-counter
Sep 1, 2026
Merged

tylerkron merged 5 commits into
mainfrom
fix/120-live-sample-counter

Conversation

@tylerkron

@tylerkron tylerkron commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Not merging — for review.

What was wrong

The live plot's headline figure was wrong by about 5x, and it was wrong in the direction that matters: it under-reported.

Streaming 16 channels at 100 Hz, the Nyquist delivers ~1,600 samples/s. The screen said ~320. The figure was incremented inside PollActiveSamples, which runs on the 50 ms render timer and can only ever see the latest value on each channel when the tick happens — at most one sample per channel per tick, i.e. a hard ceiling of 20/channel/s no matter what the device is doing. At the top selectable rate of 1000 Hz it would have been 1 in 50.

Recorded data was never affected — logging runs off the per-frame device message, not off this poll — but the number a user reads while acquiring could not be trusted, on an app whose entire job is acquisition.

The two symptoms in #120 are one root cause, not two

The issue reports two things: the counter under-reports, and the plot cannot show transients. Both fall out of the same design — the live view samples the stream at render rate instead of consuming it. They do not, however, have the same fix:

So the plot still draws one point per channel per tick and a spike shorter than 50 ms is still invisible. That is unchanged by this PR and stays with #122; nothing is lost when #120 closes.

A second defect fell out of this, and it is arguably worse

Qodo pushed on the restart case and it turned out deselecting a channel never actually turned it off. StopStreaming clears each channel's sample but not its activation, and AddChannel ORs into an enable mask built from whatever is already active. So: stream AI0–AI15, stop, deselect half, start again — the device stayed at 16 active channels while the status line read "Streaming 8 channel(s)". Those eight were still being acquired and still being logged to CSV, with no trace on the plot to account for them. Reproduced on the board and fixed here (StreamToggle reconciles the device's active analog-input set to the selection before enabling anything); numbers below.

How it is fixed

IChannel.OnChannelUpdated fires once per channel per parsed streaming frame, on the Core receive thread — the same signal LoggingManager already subscribes to, which is why the CSV was always complete. TotalSamples is now counted there, with an Interlocked.Increment and nothing else on that thread.

Three details that are load-bearing rather than stylistic:

  • Counting is driven off IsActive, not off the selection and not off which channels got a series. Those three agree after the reconciliation above; driving off IsActive keeps the readout true to its label if they ever stop agreeing, because whatever the device streams is what "samples acquired" has to count.
  • Subscriptions are tracked by reference identity. AbstractChannel.Equals compares by Name, so a HashSet<IChannel> with the default comparer would treat a replacement instance for AI0 as one already subscribed and silently stop counting it. The set uses ReferenceEqualityComparer.
  • The render tick re-arms idempotently against the current DataChannels. That is the same self-healing the poll already relied on for instance churn, now extended to the subscription. Steady state cost is one set lookup per channel per tick.
  • Counting runs on a per-stream generation. Detaching a handler cannot cancel an OnChannelUpdated invocation that has already read the channel's delegate field, so a callback from the previous stream could otherwise land after the total was reset for the next one. The handler is built per stream, closed over its generation, and teardown advances the generation before detaching.
  • The whole arming sequence is inside one try, and every DataChannels enumeration in it is snapshotted — Core replaces that list in place from its own thread. Any failure while arming lands on the same StopStream cleanup, which disarms counting and detaches.
  • The silent-stream watchdog's reference point moved. It compared the total against a value read at the top of the same poll — which only worked because the poll body did the incrementing. Once samples arrive asynchronously, that asks whether one landed during the few microseconds the body takes. See the verification below: this is not theoretical, the bench caught it tearing down a healthy connection.

Every path that ends a stream (StopStream, HandleStreamLost, HandleConnectionLost, Dispose) detaches the subscriptions.

The header text changes from "N points plotted" (#121's honest workaround for the bad number) back to "N samples acquired", which the figure now actually is.

Verification

Everything below was re-measured on the tree merged with current main. The branch's first round of evidence was gathered against base 7b54e63 (#148); main has since taken #150, #151 and #152. origin/main is merged into this branch as 87ba1eb, and every number here comes from that merged tree.

There is no test project in this repo and no dotnet test step in CI — that is the honest state of the guarding tests here, and it is why the evidence below is a bench harness rather than a unit test. The harness drives the real MobileShellViewModel (via its own AdoptConnectedDevice / StreamToggleCommand / PollActiveSamples, at the same 50 ms cadence MobileShellView's render timer uses) against the bench Nyquist over USB serial, and takes ground truth from an independent place: a subclass overriding the protected virtual DispatchDeviceMessage counts parsed streaming frames, and frames x active channels is the number of samples the device actually delivered. A separate harness-owned OnChannelUpdated counter cross-checks that; the two agreed exactly in every run.

Interaction with #150 (Services/DeviceWatcher)

#150 routed device removal through a new IDeviceWatcher seam that raises DeviceRemoved; this PR rewrites the view-model that calls ConnectionManager.RegisterConnectedDevice / UnregisterConnectedDevice / ActiveChannelCount and that changed teardown ordering. Both now drive teardown, so they were checked against each other rather than assumed independent.

On the shipped heads they cannot both be live. MobileShellViewModel is constructed only from MobileMainView, which App.OnFrameworkInitializationCompleted builds only under ISingleViewApplicationLifetime — the Android and iOS heads. DeviceWatcherFactory.Create() returns NoOpDeviceWatcher on exactly those heads (IsWindows/IsMacOS/IsLinux are all false on Android and iOS), and the desktop heads, where the watcher is real, never construct this view-model. #150 also never touched RegisterConnectedDevice, UnregisterConnectedDevice or ActiveChannelCount.

The bench is nonetheless a stricter case than production, and was run that way on purpose. It drives MobileShellViewModel on macOS, where #150's SerialPortPollingDeviceWatcher is the live backend — which the harness now prints, so the run is on record as having exercised the new seam:

hotplug backend in this process : SerialPortPollingDeviceWatcher
HotplugDetectionUnavailableMsg : (null — detection is running)
ConnectedDevices after adopt   : 1 (expect 1)

Double teardown, measured. ConnectionManager.Disconnect is the terminal action of #150's removal path; calling it on a device the shell holds makes the device's own IsConnected notification run this PR's HandleConnectionLost, which unregisters the same device again. Both halves, one disconnect, on the UI thread as in the real app:

before: ConnectedDevices=1, vm.IsConnected=True, vm.IsStreaming=True
ConnectionManager.Disconnect returned without throwing
after : ConnectedDevices=0 (expect 0)
        successful removals from ConnectedDevices : 1 (expect 1)
        ConnectedDevices change notifications     : 2
        vm.IsConnected=False, vm.IsStreaming=False
        vm._connected is null                     : True
        vm._countedChannels                       : 0
        vm.Status : "Lost connection to /dev/cu.usbmodem1101. Tap Scan to reconnect."
        repeat UnregisterConnectedDevice          : no-op, ConnectedDevices=0

One successful removal, two change notifications: HandleConnectionLost removes the device, and ConnectionManager.Disconnect's own Remove then returns false but still raises. So UnregisterConnectedDevice does its work exactly once, a repeat call is inert, and neither ordering strands an entry or throws.

ActiveChannelCount agrees with the reconciled set. Streaming 16 of 16 it reads 16; after stop → deselect 8 → restart it reads 8, matching the device's active analog inputs after StreamToggle's reconciliation.

16 channels @ 100 Hz, 30 s, merged tree

origin/main (was, on the old base) merged tree
streaming frames parsed 2,378 (79.3/s) 2,378 (79.3/s)
samples delivered (ground truth) 38,048 38,048
OnChannelUpdated events (cross-check) 38,048 38,048
on-screen figure 9,584 38,048
on-screen / delivered 25.19% 100.00%
shortfall 28,464 samples 0 samples

The origin/main column is the pre-fix measurement from the first round (three runs, 79.3 / 79.3 / 79.2 frames/s and 25.19% every time); origin/main has no fix in it to re-measure. The merged-tree column is fresh. The same run also replays the OLD counting rule alongside the new one — 9,584 in the same 30 s window, exactly 599 polls x 16 channels, so the contrast is not an artefact of comparing two sessions.

Restart with a narrower selection — the second defect, on the merged tree

Stream 16 @ 100 Hz, stop, deselect 8, restart. OnChannelUpdated events is the harness's own counter, attached to every analog input regardless of selection, so it measures what the device delivered rather than what the app counted:

restarted: IsStreaming=True  status="Streaming 8 channel(s) @ 100 Hz"
device active analog input channels now: 8 (expect 8)
ConnectionManager.ActiveChannelCount(device) now: 8
  frames                 : 1579
  delivered (frames x  8): 12632
  OnChannelUpdated events: 12632   <- across ALL analog inputs, selected or not
  vm.TotalSamples        : 12632
  counted / delivered    : 100.00%

Before the fix the same sequence left 16 channels active on the device, delivered 25,280 samples against a status line reading "Streaming 8 channel(s)", and put 12,640 on screen — 50.00%. The events row halving is the direct evidence that the deselected channels stopped being delivered, not merely stopped being counted.

The silent-stream watchdog, re-checked on the merged tree

This is the check that caught a real regression in the first round: the first version of this change looked perfect on a green build and tore healthy streams down after exactly 8 s (160 polls x 50 ms), because the watchdog's "did anything arrive?" comparison was against a value read microseconds earlier in the same poll. #150's teardown changes made it the most likely thing to have broken here. It has not:

watchdog control (count advancing): tripped at -1 (expect -1), _silentPolls=0
watchdog test    (count frozen)   : tripped at 160 (expect 160)
  status: "Lost connection to /dev/cu.usbmodem1101. Tap Scan to reconnect."
  ConnectedDevices after the watchdog teardown: 0 (expect 0)

Trips at exactly its threshold, with the right user-facing message, and its teardown unregisters cleanly from ConnectionManager — and it does not fire on healthy data.

No subscription leak

Reflecting the field-like event's backing delegate at the end of a run, on every active channel:

AI0: 2 handler(s) -> <harness counter>, <MobileShellViewModel per-stream handler>
AI1: 2 handler(s) -> <harness counter>, <MobileShellViewModel per-stream handler>
AI2: 2 handler(s) -> <harness counter>, <MobileShellViewModel per-stream handler>
vm._countedChannels: 16
distinct channel INSTANCES seen over the run: 16 (expect 16)

Exactly one handler from the view-model per channel after 600 re-arming polls, and the tracking set stays at 16.

Builds

Both sides built --no-incremental in the same worktree, against current main (ffd91ac), not the branch's original base:

Not verified

  • The Android/mobile UI itself. All of the above is the same view-model and the same code path, driven headless over USB serial on macOS rather than over Wi-Fi on a phone. The pixels were not looked at, so the new header string is verified as a string, not as a rendered layout.
  • store/screenshot-01/02 still show both the old label and the old (5x low) magnitude. Regenerating them needs the bench A16; store/listing-copy.md records what has to change so it is not discovered at submission time.

closes #120

…nder tick saw

The live plot's readout was incremented inside PollActiveSamples, which runs on the
50 ms render timer and can only ever observe the LATEST value per channel per tick.
That capped it at 20 samples per channel per second regardless of the streaming rate,
so at 16 channels / 100 Hz it reported about a fifth of what the hardware delivered —
on a data-acquisition screen, under a label that says "samples".

Count at the source instead: IChannel.OnChannelUpdated fires once per channel per
parsed streaming frame, on the Core receive thread, and is the same signal the logging
path already consumes. The handler is a single Interlocked.Increment; TotalSamples
reads back with Interlocked.Read.

Subscriptions are keyed by channel REFERENCE, not AbstractChannel.Equals (which
compares by Name), so a re-synced channel list cannot be mistaken for one already
subscribed. The render tick re-arms idempotently against the current DataChannels, the
same self-healing the poll already relied on, and every path that ends a stream —
StopStream, HandleStreamLost, HandleConnectionLost, Dispose — detaches.

Moving the count off the poll also moves the silent-stream watchdog's reference point.
It compared the total against a value read at the top of the SAME poll, which used to
work only because the poll body did the incrementing. With samples arriving
asynchronously that asks whether one landed during the few microseconds the body takes
— almost never — and the bench watched it tear a healthy 100 Hz connection down after
8-20 s. It now compares against the total at the end of the PREVIOUS poll, which is
what "no data for 8 seconds" was always meant to mean.

The plot itself is unchanged: it still draws one point per channel per tick, so a
transient shorter than 50 ms is still invisible. That half shares the same root cause
but its fix is min/max decimation, which changes the render point budget and belongs
with the rest of the render-path work in #122.

The header now reads "N samples acquired". store/listing-copy.md records that
screenshots 01 and 02 show the old label and old magnitude and need regenerating on
the bench A16 before the next submission.

closes #120

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

Count live samples at the device update source

🐞 Bug fix 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Counts every delivered channel sample at the receive event instead of render polling.
• Preserves watchdog behavior across channel replacement, stream loss, and concurrent teardown.
• Relabels the plot readout and flags affected store screenshots for regeneration.
Diagram

sequenceDiagram
    participant D as Device Stream
    participant C as Channel
    participant V as Shell ViewModel
    participant T as Render Timer
    participant P as Live Plot
    D->>C: Parse frame
    C->>V: Channel updated
    V->>V: Increment total
    T->>V: Poll latest values
    V-->>T: Values and total
    T->>P: Append and redraw
Loading
High-Level Assessment

Counting through the existing per-channel update event is the best fit because it already represents every parsed sample and is proven by the complete logging path. Keeping plotting on the render timer preserves the UI point budget, while atomic counting, reference-identity tracking, idempotent re-subscription, and comprehensive detachment address concurrency and channel replacement without expanding this correctness fix into render-path redesign.

Files changed (3) +141 / -29

Bug fix (2) +132 / -29
LivePlot.csPresent the accurate device-delivered sample total +17/-13

Present the accurate device-delivered sample total

• Renames the plot headline to “samples acquired” and documents that it represents all device-delivered samples rather than rendered points. Clarifies that trace decimation and recorded-data behavior remain unchanged.

Daqifi.Avalonia/Views/LivePlot.cs

MobileShellViewModel.csCount samples from channel update events +115/-16

Count samples from channel update events

• Moves total-sample accounting from the 20 Hz render poll to per-sample channel update events using atomic access. Tracks subscriptions by reference identity, re-arms them after channel-instance replacement, detaches them on every shutdown path, and updates the silent-stream watchdog to compare totals across polls.

Daqifi.Avalonia/Views/MobileShellViewModel.cs

Documentation (1) +9 / -0
listing-copy.mdMark live-plot store screenshots as stale +9/-0

Mark live-plot store screenshots as stale

• Documents that screenshots 01 and 02 still show the old label and under-reported magnitude. Notes the required hardware-based regeneration and confirms that the rendered trace itself is unchanged.

store/listing-copy.md

@qodo-code-review

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

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. Channel sync can abort startup ✓ Resolved 🐞 Bug ☼ Reliability ⭐ New
Description
The new subscription loop enumerates the live mutable DataChannels list outside the startup try,
so a concurrent Core channel synchronization can throw InvalidOperationException and leave sample
counting armed after the Start command fails. Even without an exception, a clear/repopulate during
this loop can leave the initial subscriptions incomplete until a later render poll repairs them.
Code

Daqifi.Avalonia/Views/MobileShellViewModel.cs[423]

+        foreach (var channel in device.DataChannels)
Relevance

●●● Strong

Recent PR #125 confirms concurrent DataChannels mutation is unsafe; snapshotting this startup
enumeration is a closely matching reliability fix.

PR-#125

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new loop directly enumerates DataChannels, while the device synchronization path mutates that
same list in place. Its placement before the startup try means the existing failure cleanup does
not catch an enumeration failure; past PR #125 confirms this mutable-list enumeration pattern is
unsafe in this codebase.

Daqifi.Avalonia/Views/MobileShellViewModel.cs[401-405]
Daqifi.Avalonia/Views/MobileShellViewModel.cs[423-440]
Daqifi.Avalonia/Daqifi.Desktop/Device/IStreamingDevice.cs[159-160]
Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1847-1886]
PR-#125

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

## Issue description
Stream startup enumerates the live mutable `DataChannels` list while Core can clear and repopulate it. A concurrent synchronization can abort startup outside the existing exception handler or produce incomplete initial subscriptions.

## Issue Context
`DataChannels` is a mutable `List<IChannel>`, and `SyncChannelsFromCore` replaces its contents with `Clear()` and `AddRange()`. The preceding reconciliation loop already snapshots this collection, but the new counting loop does not.

## Fix Focus Areas
- Daqifi.Avalonia/Views/MobileShellViewModel.cs[423-428]
- Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1847-1886]

Snapshot the channel collection before enumeration and include reconciliation, subscription setup, and initialization in guarded startup cleanup so any failure disarms and detaches counting.

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


2. Restarted streams still undercount ✓ Resolved 🐞 Bug ≡ Correctness
Description
StreamToggle subscribes the counter only to the currently selected channels, but StopStreaming
does not deactivate previously enabled channels and AddChannel preserves them in the next device
channel mask. After streaming A+B, stopping, deselecting B, and restarting A, B still produces
delivered samples while TotalSamples counts only A, so the new “samples acquired” figure remains
incorrect.
Code

Daqifi.Avalonia/Views/MobileShellViewModel.cs[R398-399]

+            // Subscribe BEFORE InitializeStreaming so no sample can arrive uncounted.
+            CountSamplesFrom(channel);
Relevance

●●● Strong

Recent accepted mobile streaming fixes show reviewers accept correctness issues involving stale
active channels and stream lifecycle state.

PR-#112
PR-#125

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The mobile stop path never removes channels, Core stop leaves IsActive unchanged, and subsequent
AddChannel calls include all previously active analog channels in the enabled mask. The parser
then assigns samples to every active analog channel, but this PR attaches OnSampleAcquired only to
channels in the new selection.

Daqifi.Avalonia/Views/MobileShellViewModel.cs[352-400]
Daqifi.Avalonia/Views/MobileShellViewModel.cs[591-599]
Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1410-1448]
Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1454-1491]
Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[850-877]
Daqifi.Avalonia/Daqifi.Desktop/Channel/AbstractChannel.cs[188-227]

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

## Issue description
Restarting with a subset of previously selected channels leaves old channels active on the device, while the new counter subscribes only to the current selection. This causes `TotalSamples` to omit samples that the device still delivers.

## Issue Context
`AbstractStreamingDevice.StopStreaming()` clears samples but not channel activity, and `AddChannel()` builds its command from the existing active-channel set. Before starting, make the active analog-input set match the current selection (including disabling deselected channels), then subscribe to the current instances that will actually stream.

## Fix Focus Areas
- Daqifi.Avalonia/Views/MobileShellViewModel.cs[352-406]
- Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1410-1491]
- Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[850-877]

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



Remediation recommended

3. Stale callback contaminates restart ✓ Resolved 🐞 Bug ≡ Correctness ⭐ New
Description
Setting _countingSamples false and unsubscribing does not stop an event invocation that already
captured OnSampleAcquired, whose unconditional increment can run after the counter is reset for
the next stream. A rapid stop/restart can therefore attribute a sample from the old stream to the
new stream and make the advertised acquisition total inaccurate.
Code

Daqifi.Avalonia/Views/MobileShellViewModel.cs[565]

+            _countingSamples = false;
Relevance

●● Moderate

Recent history accepts lifecycle race protections, but no close precedent specifically confirms
stale event callbacks crossing a counter reset.

PR-#26

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Channel events are invoked directly without taking the view-model lock, so unsubscribe cannot revoke
a delegate invocation that has already begun. The restart sequence detaches, resets the total, and
re-arms counting, while the callback always increments, allowing an old invocation to land in the
new total.

Daqifi.Avalonia/Views/MobileShellViewModel.cs[385-392]
Daqifi.Avalonia/Views/MobileShellViewModel.cs[547-570]
Daqifi.Avalonia/Views/MobileShellViewModel.cs[574-580]
Daqifi.Avalonia/Daqifi.Desktop/Channel/AbstractChannel.cs[285-292]

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

## Issue description
An `OnChannelUpdated` invocation already in progress can execute after `StopCountingSamples` detaches the handler. If a new stream resets and re-arms the counter first, that old callback increments the new stream's total.

## Issue Context
Event invocation is not synchronized with the view-model's `_countedChannels` lock, and `OnSampleAcquired` increments unconditionally. The boolean gate cannot distinguish an old callback from a callback belonging to the newly armed stream.

## Fix Focus Areas
- Daqifi.Avalonia/Views/MobileShellViewModel.cs[527-570]
- Daqifi.Avalonia/Views/MobileShellViewModel.cs[579-580]
- Daqifi.Avalonia/Daqifi.Desktop/Channel/AbstractChannel.cs[285-292]

Associate subscriptions with a stream generation and have each callback reject a generation that is no longer current before incrementing. Advance the generation before teardown/reset and retain each generated delegate so it can be unsubscribed correctly.

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


Grey Divider

Context sources
Review mode: ⚖️ Balanced: This push changes stream lifecycle cleanup, channel reconciliation, and cross-thread sample-counting generation logic; it has real concurrency and acquisition-state risk, but remains a localized single-file change suitable for one careful review pass.

Grey Divider

Tip of the day
💡 Did you know, you can describe a rule in plain language on the Rules page and Qodo drafts it for you

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit 87ba1eb

Results up to commit 4c6522e ⚖️ Balanced


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


Action required
1. Restarted streams still undercount ✓ Resolved 🐞 Bug ≡ Correctness
Description
StreamToggle subscribes the counter only to the currently selected channels, but StopStreaming
does not deactivate previously enabled channels and AddChannel preserves them in the next device
channel mask. After streaming A+B, stopping, deselecting B, and restarting A, B still produces
delivered samples while TotalSamples counts only A, so the new “samples acquired” figure remains
incorrect.
Code

Daqifi.Avalonia/Views/MobileShellViewModel.cs[R398-399]

+            // Subscribe BEFORE InitializeStreaming so no sample can arrive uncounted.
+            CountSamplesFrom(channel);
Relevance

●●● Strong

Recent accepted mobile streaming fixes show reviewers accept correctness issues involving stale
active channels and stream lifecycle state.

PR-#112
PR-#125

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The mobile stop path never removes channels, Core stop leaves IsActive unchanged, and subsequent
AddChannel calls include all previously active analog channels in the enabled mask. The parser
then assigns samples to every active analog channel, but this PR attaches OnSampleAcquired only to
channels in the new selection.

Daqifi.Avalonia/Views/MobileShellViewModel.cs[352-400]
Daqifi.Avalonia/Views/MobileShellViewModel.cs[591-599]
Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1410-1448]
Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1454-1491]
Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[850-877]
Daqifi.Avalonia/Daqifi.Desktop/Channel/AbstractChannel.cs[188-227]

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

## Issue description
Restarting with a subset of previously selected channels leaves old channels active on the device, while the new counter subscribes only to the current selection. This causes `TotalSamples` to omit samples that the device still delivers.

## Issue Context
`AbstractStreamingDevice.StopStreaming()` clears samples but not channel activity, and `AddChannel()` builds its command from the existing active-channel set. Before starting, make the active analog-input set match the current selection (including disabling deselected channels), then subscribe to the current instances that will actually stream.

## Fix Focus Areas
- Daqifi.Avalonia/Views/MobileShellViewModel.cs[352-406]
- Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[1410-1491]
- Daqifi.Avalonia/Daqifi.Desktop/Device/AbstractStreamingDevice.cs[850-877]

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


Grey Divider

Qodo Logo

Qodo (PR #153, High) found that restarting a stream with a narrower selection left the
counter under the truth again. Reproduced on the bench: stream 16 channels, stop,
deselect 8, restart — the device stayed at 16 active analog inputs while the shell said
"Streaming 8 channel(s)", and TotalSamples reported 50.00% of what was delivered.

The cause is not in the counter. StopStreaming clears each channel's sample but not its
activation, and AddChannel ORs into an enable mask built from whatever is already
active, so nothing ever turned a deselected channel off. It kept being acquired and
logged, with no trace on the plot to show for it — the counter is just where it became
visible.

StreamToggle now removes active analog inputs that are not in the selection before
enabling the ones that are, so the device's active set matches what the user picked.

Counting is also driven off IsActive rather than off the selection or off which channels
got a series. Those three agree after the change, and this keeps the readout true to its
label if they ever stop agreeing: whatever the device streams is what "samples acquired"
has to count.

Also gates the render tick's re-arm behind an armed flag. HandleStreamLost and
HandleConnectionLost can be reached from a device property-change raised on the Core
thread; without the gate a poll already inside its channel loop could re-attach a
subscription just after teardown detached it, and that one would keep counting for a
stream that is over.

Bench, 16ch @ 100 Hz, restart with 8 selected:
  before: 16 active, 25,280 delivered, 12,640 counted (50.00%)
  after :  8 active, 12,640 delivered, 12,640 counted (100.00%)
A 60 s straight run is still exact (76,144 / 76,144) and the silent-stream watchdog
still trips at exactly 160 polls when the count is frozen, and never while it advances.

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 be1dabb

tylerkron and others added 2 commits August 31, 2026 19:32
…under one try

Qodo (PR #153, High): the new subscription loop enumerated the live DataChannels list,
which Core's SyncChannelsFromCore replaces in place with Clear() + AddRange() from its
own thread. A re-sync landing mid-enumeration throws InvalidOperationException, and the
loop sat OUTSIDE the try that cleans up a failed start — so the throw would escape the
command with sample counting still armed. The reconciliation loop above it already
snapshotted; this one did not.

Snapshots it, and moves channel reconciliation, series setup, subscription and
InitializeStreaming into a single try so any failure in the arming sequence lands on the
existing StopStream cleanup, which disarms counting and detaches every subscription.

A re-sync between snapshot and subscribe can still leave a subscription on a replaced
instance. That is harmless here and noted at the site: the device is not streaming yet,
and the render tick's idempotent re-arm repairs it within 50 ms.

Re-verified on the bench: 30 s straight run 38,048 delivered / 38,048 counted (100.00%,
shortfall 0); restart with 8 of 16 selected reconciles the device to 8 active and counts
12,632 of 12,632 (100.00%); 60 s soak clean; watchdog still trips at exactly 160 polls
on a frozen count and never over 500 advancing ones. Desktop 297 and iOS Release 1253
warnings, both unchanged from origin/main built --no-incremental the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Qodo (PR #153, Medium): detaching a handler cannot cancel an OnChannelUpdated invocation
that has already read the channel's delegate field. A callback from the previous stream
can therefore still run after StopCountingSamples, and if the user restarts quickly
enough it lands after the total has been reset — crediting the old stream's sample to
the new one. The boolean armed flag could not tell the two apart.

Counting now runs on a generation. BeginCountingSamples takes a fresh generation and
builds the handler closed over it; the handler drops its sample unless the generation is
still current; StopCountingSamples advances the generation BEFORE detaching, so every
callback the detach cannot reach becomes a no-op. The delegate is retained so the same
instance is the one unsubscribed.

The cost on the receive thread is one volatile read before the interlocked add.

Re-verified: 30 s straight run 38,048 / 38,048 (100.00%); restart with 8 of 16 selected
— the exact case this protects, since the counter resets while the old stream's frames
are still draining — reconciles to 8 active and counts 12,632 of 12,632 (100.00%); 60 s
soak clean; watchdog trips at exactly 160 polls frozen and never over 500 advancing.
Reflecting the event's backing delegate still shows exactly one handler per channel from
the view-model. Desktop 297 / iOS Release 1253 warnings, unchanged.

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 459f77e

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

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

Copy link
Copy Markdown

No code changes since the last review — review skipped

Qodo Logo

@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo-clean, CI green, re-benched on current main — ready for review. Merged origin/main (ffd91ac, which added #150/#151/#152) into the branch as 87ba1eb and re-validated everything on the merged tree: counter 38,048/38,048 = 100.00% at 16ch @ 100 Hz, restart-with-8-selected 12,632/12,632 = 100.00%, watchdog control never trips and the frozen-count case trips at exactly 160 with the right status. The #150 DeviceWatcher seam is checked against this PR's teardown rather than assumed independent — the double teardown does its work exactly once, and the bench ran with SerialPortPollingDeviceWatcher actually live, which is a stricter combination than any shipped head produces. Warning baselines re-measured against current main: desktop 292 → 292, iOS Release 1248 → 1248. Details in the PR body.

@qodo-code-review

Copy link
Copy Markdown

Qodo-clean, CI green, re-benched on current main — ready for review. Merged origin/main (ffd91ac, which added #150/#151/#152) into the branch as 87ba1eb and re-validated everything on the merged tree: counter 38,048/38,048 = 100.00% at 16ch @ 100 Hz, restart-with-8-selected 12,632/12,632 = 100.00%, watchdog control never trips and the frozen-count case trips at exactly 160 with the right status. The #150 DeviceWatcher seam is checked against this PR's teardown rather than assumed independent — the double teardown does its work exactly once, and the bench ran with SerialPortPollingDeviceWatcher actually live, which is a stricter combination than any shipped head produces. Warning baselines re-measured against current main: desktop 292 → 292, iOS Release 1248 → 1248. Details in the PR body.

Thanks for the update. The reported merged-tree validation addresses all three previously raised items:

  • finding 1: channel synchronization is guarded with snapshots and startup cleanup.
  • finding 2: restart reconciliation ensures every active streamed channel is counted.
  • finding 3: generation checks reject stale callbacks across restarts.

With the 100% counter/restart results, watchdog checks, live watcher teardown validation, and unchanged warning baselines, there are currently no active Qodo findings.

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.

The live plot decimates to 20 Hz and the 'N samples' counter under-reports by 5x at 100 Hz

1 participant