Skip to content

fix(firmware): device discovery kept probing the bus during a manual HID flash - #178

Merged
tylerkron merged 4 commits into
mainfrom
fix/168-pause-discovery-for-hid-flash
Sep 2, 2026
Merged

tylerkron merged 4 commits into
mainfrom
fix/168-pause-discovery-for-hid-flash

Conversation

@tylerkron

@tylerkron tylerkron commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Not merging — for review. closes #168 (parity gap #5 from the 2026-09-01 audit; upstream 52257bdf, their issue #777).

What was wrong

While a DAQiFi board is being written, nothing may touch the bus. The serial finder opens and probes every DAQiFi COM port each cycle and the WiFi finder UDP-broadcasts; either can starve a bootloader's HID I/O mid-write. The app knew this and paused discovery — but only for an auto firmware update.

A manual HID flash never sets ConnectionManager.DeviceBeingUpdated, so it is invisible to IsFirmwareUpdateInProgress, which was the only thing the connection dialog gated on. Two concrete ways the bus came back live in the middle of a flash:

  1. Another device's auto-update finishing. On a two-board bench, board A finishes auto-updating while the user is in the firmware dialog for board B, picking a .hex. OnFirmwareUpdateInProgressChanged restarted serial and WiFi discovery unconditionally — so by the time the user hit Upload, every COM port was being probed and UDP broadcasts were going out.
  2. Closing the dialog. ConnectHid's finally restarted both transports the instant the modal closed. But closing mid-flash only cancels: the watcher's flash lease is released asynchronously and can outlive the dialog, so discovery came back while the HID write was still unwinding.

Bus contention during a flash is intermittent, but when it lands it strands the update — and this is a multi-device bench workflow.

What changed

The pause is now a three-reason guard. ConnectionDialogViewModel.IsDiscoveryPausedForFirmware is true when any of these holds:

reason covers
ConnectionManager.IsFirmwareUpdateInProgress a coordinator auto-update (unchanged)
_hidFirmwareDialogOpen the whole HID firmware dialog window, including the time the user spends choosing a file
IBootloaderWatcher.IsFlashInProgress a bootloader write still in flight, even after the dialog has closed

Both StartWiFiDiscovery and StartSerialDiscovery consult it, so a restart requested while any reason is active is refused. The mirror-image risk — a guard that refuses restarts leaving discovery dead for the rest of the dialog's life — is handled by having every reason call RestartDiscoveryAfterFirmwarePause() when it clears: whichever clears last is the one that actually restarts. That is also why the dialog now subscribes to the watcher's new FlashInProgressChanged: when the write outlives the dialog, its falling edge is the only trigger left.

ConnectHid's body moved into RunWithHidFlashQuiescedAsync, which raises the flag before awaiting the drains — an auto-update can finish while we are still tearing discovery down, and would otherwise restart the very thing being torn down.

Plus one ordering bug in BootloaderWatcher.ResumeAfterFlashAsync. It cleared its flash marker at the top, before await hold.BeginHoldAsync() — so the watcher advertised "flash over" while the HID handle was still being reopened, which is exactly the window a resumed COM-port probe would land in. The marker is now released in a finally after the re-grab: late enough to cover the handoff, unconditional enough that a re-grab failure cannot strand it set (which would pause discovery permanently).

New on IBootloaderWatcher: IsFlashInProgress and FlashInProgressChanged. BootloaderWatcher is the only implementor. FirmwareDialogViewModel needed no change — it already hands off through PrepareFlashAsync.

And one thing beyond the upstream port, from review. PrepareFlashAsync hands out an independent lease per call, but the watcher recorded a single _flashingPath — so if two flashes ever overlapped, disposing the first lease would clear the shared state, resume HID discovery and open the dialog's guard while the second write was still on the bus. That is not reachable through today's UI (the only caller sits behind a modal opened from another modal), but IsFlashInProgress is now a published contract read by a component holding no lock, so the state has to mean "any lease outstanding". It is now a counted set of paths plus a single-field total for the lock-free reader, and the post-flash re-grab is likewise deferred to the last lease for that path. Behaviour is byte-for-byte identical for a single flash — the only case reachable today — so this cannot regress the path I am not allowed to bench-test.

And one failure-path fix, also from review. The flash marker is raised before the target's hold is released, deliberately, so an auto-update ending during that release cannot see a gap. The cost was that a throwing ReleaseAsync exits without returning a lease — and the lease is the only thing that ever clears the marker. That stranding pre-dates this PR, but its blast radius did not: before, a stranded marker only paused the watcher's own HID discovery; now the connection dialog keys serial and WiFi on the same flag, so one failed release would leave the whole bus unscanned for the life of the process, over a flash that never started. The release is now wrapped, the marker rolled back on failure, discovery resumed if nothing else needs it paused, and the exception rethrown — so the caller still sees the failure.

That fix then needed one of its own, which review also caught. IsFlashInProgress is polled, not only subscribed to: between the increment and the rollback it reads true, and the dialog's guards can refuse a restart on the strength of it — a coordinator auto-update ending inside that window is exactly the case. Rolling back silently left that refusal with nothing to retry it: the same permanent stranding, moved one level up. The edge is now announced from a finally so it fires on the failure path too. No rising edge is announced there (the rethrow precedes it), so it is an unpaired falling edge — which is precisely the signal a subscriber needs: whatever refused you is gone, ask again.

Verification

19 new xUnit tests, 0 failures. The guard is a small state machine, so it is pinned directly rather than described.

Every figure in this section was measured against base 4a8c338 — current main at the time of writing, which this branch is rebased onto — where the suite goes 133 → 152.

Every claim below was checked by reverting the fix and re-running, not by reading:

reverted behaviour red
IsDiscoveryPausedForFirmware reduced to the connection-manager term alone (what main does) 5 of the 9 dialog tests
the flash marker cleared before the re-grab (what main does) 4 of the 10 watcher tests
the pause flag raised after the drains instead of before 1 (The_pause_is_in_place_before_the_transports_finish_draining)
flash state back to one shared slot (review round 1) 1 (Disposing_one_of_two_overlapping_leases_leaves_the_flash_in_progress)
no rollback when the hold release throws (review round 3) 1 (A_preparation_that_fails_to_release_the_hold_does_not_strand_the_pause)
the rollback done silently, with no falling edge (review round 4) 1 (same test — it asserts the edge as well as the state)

Being precise about the four dialog tests that stayed green, since "5 of 9" on its own would flatter them: two of them (Nothing_is_paused…, An_auto_update_pauses_discovery) cover the auto-update reason, which main already had. Closing_the_dialog_unsubscribes… guards a handler main does not have, so it cannot fail there. And The_pause_clears_when_the_auto_update_finishes_after_the_bootloader_write passes vacuously against main — with only one reason, of course it clears; it is there to catch the new failure mode this PR could introduce, a guard that refuses restarts forever. Its mirror (…_when_the_bootloader_write_finishes_after_the_auto_update) does go red, and it is the one that proves the watcher's falling edge is wired up.

Two of these are windows rather than states, and a window can only be tested by suspending the code inside it. The re-grab test parks the fake hold's BeginHoldAsync on a TaskCompletionSource and reads IsFlashInProgress while the re-grab is suspended. The drain-ordering test exploits the fact that an async method runs synchronously up to its first real suspension: it parks the discovery-loop task, and control returning from the call is itself proof that the window is open. Neither uses a sleep.

CI: on the earlier pushes, Desktop-macOS pass, Android head pass (the head this Mac has no workload for, so CI is the only place it is proven), CI self-tests pass, and the Linux Library + Desktop head + capture harness job pass with the full suite green — worth naming, because the dispatcher this PR's test seam works around behaves differently on Linux and macOS, and both hosts agree.

Gate (both sides at 4a8c338, see above). Desktop 250 → 250 warnings, iOS Release iossimulator-arm64 251 → 251, 0 errors on both. Measured against a merge base built in this same worktree with the same discipline — Daqifi.Avalonia/obj, Daqifi.Avalonia.Desktop/obj, Daqifi.Avalonia.iOS/obj and third_party/oxyplot-avalonia/OxyPlot.Avalonia/obj all cleared before each side, so the vendored OxyPlot's 20 warnings are emitted on both. The comparison that actually matters is the distinct first-party warning-site set (file(line,col): warning CODE, third_party/ excluded): 182 on both sides, the only difference being two pre-existing warnings in ConnectionDialogViewModel.cs whose line numbers moved. packages.lock.json untouched (restored without -r).

Not verified by hardware, and why

No flash was performed. A firmware write is on this session's destructive denylist, so the acceptance criterion in #168 — watch discovery state across a real HID flash on two boards — is not met by this PR. What is established is the state machine (unit-tested, both directions) and the reasoning above. A human should still run one real manual flash with a second board attached before trusting it in the field.

Two smaller things the tests cannot reach:

  • _hidFirmwareDialogOpen is set around the real modal in production; the tests drive RunWithHidFlashQuiescedAsync with a delegate standing in for it.
  • The falling edge of a real flash comes from FirmwareDialogViewModel disposing the watcher's lease. That path is unchanged and untested here; the watcher tests exercise the lease directly.

Test seam worth flagging in review

ConnectionDialogViewModel's UI marshal moved from a direct call to a private _marshalToUiThread field (still initialised to the same static method — production behaviour is identical). This is not tidiness: outside a running Avalonia app, Dispatcher.UIThread binds to whichever thread first touches it and is never pumped, so the production marshal's blocking Invoke never returns when reached from any other thread, and xUnit gives no thread affinity. Without the seam these tests would deadlock the test host rather than fail. Reached by reflection, so no InternalsVisibleTo and no .csproj edit. That was originally to stay off the one file #172, #173 and #174 were all editing; those have since merged and Daqifi.Avalonia.csproj now carries InternalsVisibleTo, so the seam could be converted — but it works as it is, and swapping it now would cost a review round for no behaviour change. Happy to convert it if you would rather these tests used the same mechanism as #172's.

Collision check

Diffed #172, #173 and #174 before starting, when all three were open. main then moved five times while this PR was in review, so the check was redone against each: this branch is rebased onto 4a8c338 and re-measured there, and none of #172, #173, #174, #176 or #177 touches ConnectionDialogViewModel.cs, BootloaderWatcher.cs or IBootloaderWatcher.cs.

Three specific interactions I went looking for, none of which bites:

All three heads plus the test project build and pass on the rebase.

No .portomatic/map/ edit; the @port: markers for the new members are in the code.

🤖 Generated with Claude Code

@tylerkron

Copy link
Copy Markdown
Contributor Author

/agentic_review

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Pause discovery throughout manual HID firmware flashes

🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Pause serial and WiFi discovery throughout manual HID firmware dialogs and writes.
• Resume discovery only after all firmware guards and bootloader re-grabs finish.
• Add regression coverage for overlapping updates, asynchronous teardown, and subscriber failures.
Diagram

graph TD
  A["Auto Update"] -->|"pause reason"| G{"Pause Guard"} -->|"allows when clear"| S["Serial Discovery"]
  D["HID Dialog"] -->|"pause reason"| G
  W["Flash Watcher"] -->|"flash state"| G
  W -->|"falling edge"| R["Restart Trigger"] -->|"rechecks guard"| G
  G -->|"allows when clear"| F["WiFi Discovery"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Central discovery quiescence coordinator
  • ➕ Provides reference-counted leases for every bus-sensitive operation.
  • ➕ Centralizes pause ownership and removes multi-reason checks from the dialog.
  • ➕ Scales cleanly if additional firmware workflows are introduced.
  • ➖ Requires a broader lifecycle and dependency-injection refactor.
  • ➖ Increases regression risk beyond the reported manual-flash defect.
  • ➖ Existing watcher and connection-manager state would need migration or adaptation.

Recommendation: Keep the PR's targeted three-reason guard for this fix. It derives state from the existing operation owners, preserves current discovery lifecycles, and safely retries when each reason clears; a centralized lease coordinator is worth considering only if more bus-quiescence workflows emerge.

Files changed (5) +977 / -41

Bug fix (3) +235 / -41
BootloaderWatcher.csPublish reliable HID flash lifecycle state +77/-12

Publish reliable HID flash lifecycle state

• Exposes volatile flash-in-progress state and raises protected transition notifications outside the watcher lock. The flash marker now remains set through post-flash handle re-grab and clears in a finally block so discovery resumes after both successful and failed handoffs.

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs

IBootloaderWatcher.csExpose bootloader flash state and transition events +19/-0

Expose bootloader flash state and transition events

• Extends the watcher contract with IsFlashInProgress and FlashInProgressChanged. This lets discovery consumers observe manual HID writes that are not represented by ConnectionManager firmware state.

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/IBootloaderWatcher.cs

ConnectionDialogViewModel.csGuard discovery across the complete manual HID flash lifecycle +139/-29

Guard discovery across the complete manual HID flash lifecycle

• Combines auto-update state, HID dialog lifetime, and watcher flash state into one serial and WiFi discovery guard. The dialog raises its pause before draining discovery, retries guarded restarts whenever a reason clears, and subscribes to asynchronous flash completion while preventing handler leaks on close.

Daqifi.Avalonia/Daqifi.Desktop/ViewModels/ConnectionDialogViewModel.cs

Tests (2) +742 / -0
BootloaderWatcherFlashStateTests.csCover bootloader flash-state transitions and resume ordering +320/-0

Cover bootloader flash-state transitions and resume ordering

• Adds hardware-free tests for flash-state edges, delayed and failed post-flash re-grabs, overlapping discovery suspension, and duplicate preparations. It also verifies that one failing event subscriber cannot suppress notifications to later subscribers.

Daqifi.Avalonia.Tests/Device/Firmware/BootloaderWatcherFlashStateTests.cs

ConnectionDialogFirmwarePauseTests.csCover the connection dialog's three-way firmware pause guard +422/-0

Cover the connection dialog's three-way firmware pause guard

• Adds regression tests for auto-update, open HID dialog, and active bootloader-write pause reasons in both completion orders. Tests also verify early quiescing, guarded restart behavior, and watcher event unsubscription when the dialog closes.

Daqifi.Avalonia.Tests/ViewModels/ConnectionDialogFirmwarePauseTests.cs

@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. Failed preparation loses restart ✓ Resolved 🐞 Bug ☼ Reliability ⭐ New
Description
PrepareFlashAsync publishes IsFlashInProgress = true before awaiting hold release, but its
failure rollback clears the state without raising FlashInProgressChanged. If another pause reason
clears during that await, the connection dialog's restart is refused by the transient state and is
never retried, leaving serial and WiFi discovery stopped.
Code

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[R150-151]

+                ReleaseFlashSlot(devicePath);
+                ResumeDiscoveryIfIdle();
Relevance

●●● Strong

Failure rollback clears a published guard without notification, matching accepted patterns for
cleanup and lifecycle recovery bugs.

PR-#150
PR-#153

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The watcher writes a positive _outstandingFlashes value before awaiting ReleaseAsync, and
IsFlashInProgress exposes that value immediately. The catch path then calls ReleaseFlashSlot and
resumes only the watcher's HID discovery, while the event is raised only on the later success path;
meanwhile, the connection dialog relies on the watcher's falling-edge event to retry guarded
serial/WiFi restarts.

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[128-165]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/ConnectionDialogViewModel.cs[239-280]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/ConnectionDialogViewModel.cs[329-333]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/ConnectionDialogViewModel.cs[379-382]

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 `PrepareFlashAsync` clears the externally visible flash-in-progress state without notifying subscribers. This can permanently suppress the connection dialog's serial and WiFi discovery when another pause reason attempts its final restart during the failed hold release.

## Issue Context
`_outstandingFlashes` must become nonzero before `ReleaseAsync` to close the discovery race, so the rollback transition must be observable too. Arrange for `FlashInProgressChanged` to be raised after `_gate` is released when rollback changes the overall state from active to inactive, while preserving the original preparation exception, and add an end-to-end regression test covering an auto-update clearing while the failing release is pending.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[121-168]
- Daqifi.Avalonia.Tests/ViewModels/ConnectionDialogFirmwarePauseTests.cs[92-132]
- Daqifi.Avalonia.Tests/Device/Firmware/BootloaderWatcherFlashStateTests.cs[238-260]

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


2. Overlapping flash state clears early ✓ Resolved 🐞 Bug ≡ Correctness
Description
PrepareFlashAsync overwrites the single _flashingPath for each independently returned lease,
while disposing any lease unconditionally clears that shared state and resumes discovery. With
overlapping flashes, disposing the first lease incorrectly reports that no flash is in progress and
restarts HID, serial, and WiFi discovery—and may release the connection-dialog guard—while the
second flash still owns and writes to the bus.
Code

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[R112-113]

+            flashStarted = _flashingPath == null;
+            Volatile.Write(ref _flashingPath, devicePath);
Relevance

●●● Strong

Accepted concurrency lifecycle bugs recently; overlapping leases violate the documented
independent-disposal contract and can resume discovery prematurely.

PR-#155
PR-#150

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Every preparation returns an independent disposal lease, but the watcher's public flash-in-progress
state is derived from a single replaceable path. Each lease's resume path clears that shared marker
and calls ResumeDiscoveryIfIdle without checking for other active flash leases, even though the
interface defines the state as lasting until the returned lease is disposed; the newly added test
confirms that a second PrepareFlashAsync is allowed while the first remains active, but does not
verify disposal ordering or behavior.

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[34-36]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[158-199]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[250-269]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/IBootloaderWatcher.cs[37-54]
Daqifi.Avalonia.Tests/Device/Firmware/BootloaderWatcherFlashStateTests.cs[174-190]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[101-133]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[158-198]

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

## Issue description
`BootloaderWatcher` represents all active manual flashes with one `_flashingPath`. A second `PrepareFlashAsync` overwrites that value, and disposing either lease clears the global flash state and permits the connection-dialog guard and HID, serial, and WiFi discovery to resume while another lease remains active and may still be writing to the bus.

## Issue Context
The API permits another `PrepareFlashAsync` while a flash is already in progress, and every call returns an independent disposal lease. The public flash state and falling-edge event must represent the aggregate lifetime of all active leases, keeping discovery stopped until the final active flash lease has completed; the added tests exercise a second preparation while the first remains active but do not cover disposal ordering.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[101-133]
- Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[158-199]
- Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[250-269]
- Daqifi.Avalonia.Tests/Device/Firmware/BootloaderWatcherFlashStateTests.cs[174-210]

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



Remediation recommended

3. Failed preparation strands pause ✓ Resolved 🐞 Bug ☼ Reliability
Description
PrepareFlashAsync increments the published flash count before awaiting ReleaseAsync, but an
exception exits without returning a lease or rolling the count back. IsFlashInProgress then
remains true indefinitely, suppressing HID discovery and the connection dialog's serial/WiFi
discovery even though no flash started.
Code

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[R128-130]

+            flashStarted = _outstandingFlashes == 0;
+            _flashingPaths[devicePath] = _flashingPaths.GetValueOrDefault(devicePath) + 1;
+            Volatile.Write(ref _outstandingFlashes, _outstandingFlashes + 1);
Relevance

●●● Strong

Accepted reliability precedent favors cleanup on partial asynchronous startup to prevent permanently
stranded state.

PR-#150

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The counters are incremented before the awaited hold release, while the only decrement path is
invoked by disposing the lease returned after that await. The caller stores and disposes only a
successfully returned lease, and the newly added connection-dialog guard directly treats the
stranded counter as an active firmware operation.

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[121-150]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[227-247]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/FirmwareDialogViewModel.cs[267-327]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/ConnectionDialogViewModel.cs[277-280]

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

## Issue description
`PrepareFlashAsync` publishes an outstanding flash before releasing the target hold. If hold release fails, no lease is returned, so the outstanding state can never be cleared and all discovery remains paused.

## Issue Context
Rollback the per-path and total counters while holding `_gate`, resume discovery when otherwise idle, and preserve correct event-edge semantics. Add a test with a hold whose `ReleaseAsync` throws.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[121-150]
- Daqifi.Avalonia.Tests/Device/Firmware/BootloaderWatcherFlashStateTests.cs[62-100]

ⓘ 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 localized but behaviorally significant asynchronous firmware/discovery state change where incorrect event timing could strand or resume bus access, warranting a careful single-pass review.

Grey Divider

Tip of the day
💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Previous reviews

Review updated until commit cffbed6

Results up to commit 9d60d3f 🧠 Deep


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


Action required
1. Overlapping flash state clears early ✓ Resolved 🐞 Bug ≡ Correctness
Description
PrepareFlashAsync overwrites the single _flashingPath for each independently returned lease,
while disposing any lease unconditionally clears that shared state and resumes discovery. With
overlapping flashes, disposing the first lease incorrectly reports that no flash is in progress and
restarts HID, serial, and WiFi discovery—and may release the connection-dialog guard—while the
second flash still owns and writes to the bus.
Code

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[R112-113]

+            flashStarted = _flashingPath == null;
+            Volatile.Write(ref _flashingPath, devicePath);
Relevance

●●● Strong

Accepted concurrency lifecycle bugs recently; overlapping leases violate the documented
independent-disposal contract and can resume discovery prematurely.

PR-#155
PR-#150

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Every preparation returns an independent disposal lease, but the watcher's public flash-in-progress
state is derived from a single replaceable path. Each lease's resume path clears that shared marker
and calls ResumeDiscoveryIfIdle without checking for other active flash leases, even though the
interface defines the state as lasting until the returned lease is disposed; the newly added test
confirms that a second PrepareFlashAsync is allowed while the first remains active, but does not
verify disposal ordering or behavior.

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[34-36]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[158-199]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[250-269]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/IBootloaderWatcher.cs[37-54]
Daqifi.Avalonia.Tests/Device/Firmware/BootloaderWatcherFlashStateTests.cs[174-190]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[101-133]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[158-198]

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

## Issue description
`BootloaderWatcher` represents all active manual flashes with one `_flashingPath`. A second `PrepareFlashAsync` overwrites that value, and disposing either lease clears the global flash state and permits the connection-dialog guard and HID, serial, and WiFi discovery to resume while another lease remains active and may still be writing to the bus.

## Issue Context
The API permits another `PrepareFlashAsync` while a flash is already in progress, and every call returns an independent disposal lease. The public flash state and falling-edge event must represent the aggregate lifetime of all active leases, keeping discovery stopped until the final active flash lease has completed; the added tests exercise a second preparation while the first remains active but do not cover disposal ordering.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[101-133]
- Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[158-199]
- Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[250-269]
- Daqifi.Avalonia.Tests/Device/Firmware/BootloaderWatcherFlashStateTests.cs[174-210]

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


Results up to commit 107956a ⚖️ Balanced


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


Remediation recommended
1. Failed preparation strands pause ✓ Resolved 🐞 Bug ☼ Reliability
Description
PrepareFlashAsync increments the published flash count before awaiting ReleaseAsync, but an
exception exits without returning a lease or rolling the count back. IsFlashInProgress then
remains true indefinitely, suppressing HID discovery and the connection dialog's serial/WiFi
discovery even though no flash started.
Code

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[R128-130]

+            flashStarted = _outstandingFlashes == 0;
+            _flashingPaths[devicePath] = _flashingPaths.GetValueOrDefault(devicePath) + 1;
+            Volatile.Write(ref _outstandingFlashes, _outstandingFlashes + 1);
Relevance

●●● Strong

Accepted reliability precedent favors cleanup on partial asynchronous startup to prevent permanently
stranded state.

PR-#150

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The counters are incremented before the awaited hold release, while the only decrement path is
invoked by disposing the lease returned after that await. The caller stores and disposes only a
successfully returned lease, and the newly added connection-dialog guard directly treats the
stranded counter as an active firmware operation.

Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[121-150]
Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[227-247]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/FirmwareDialogViewModel.cs[267-327]
Daqifi.Avalonia/Daqifi.Desktop/ViewModels/ConnectionDialogViewModel.cs[277-280]

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

## Issue description
`PrepareFlashAsync` publishes an outstanding flash before releasing the target hold. If hold release fails, no lease is returned, so the outstanding state can never be cleared and all discovery remains paused.

## Issue Context
Rollback the per-path and total counters while holding `_gate`, resume discovery when otherwise idle, and preserve correct event-edge semantics. Add a test with a hold whose `ReleaseAsync` throws.

## Fix Focus Areas
- Daqifi.Avalonia/Daqifi.Desktop/Device/Firmware/BootloaderWatcher.cs[121-150]
- Daqifi.Avalonia.Tests/Device/Firmware/BootloaderWatcherFlashStateTests.cs[62-100]

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


Grey Divider

Qodo Logo

@tylerkron

Copy link
Copy Markdown
Contributor Author

Round 1 triage — 1 finding, accepted and fixed in e5e6418

"Overlapping flash state clears early" — accepted.

I checked reachability before acting, and it is worth recording: PrepareFlashAsync has exactly one caller in the tree, FirmwareDialogViewModel.cs:271, reached only from ConnectionDialogViewModel.ConnectHid — a modal dialog opened from inside another modal dialog, both behind non-concurrent [RelayCommand]s. The auto-update coordinator uses the other lease (SuspendDiscoveryAsync, tracked by _grabSuppressed, already interleaved correctly). So two overlapping PrepareFlashAsync calls cannot happen through today's UI.

That does not make you wrong, for two reasons, and both are things I introduced in this PR:

  1. IsFlashInProgress is now a published contract, documented as "between PrepareFlashAsync and the disposal of the lease it returned", and read by a component that holds no lock (ConnectionDialogViewModel's Start*Discovery guards). A single replaceable slot cannot honour a per-lease contract.
  2. Your evidence section names the real problem precisely: my new test "confirms that a second PrepareFlashAsync is allowed while the first remains active, but does not verify disposal ordering". I added a test that advertises support for a case the implementation did not support. That is worse than the latent bug.

Fix. _flashingPath (one nullable string) → _flashingPaths (a Dictionary<string,int> of paths with outstanding leases) plus _outstandingFlashes, a single-field total so the lock-free reader still reads one field rather than walking a dictionary. ReleaseFlashSlot drops one lease and reports whether that was the last of any kind — which is now what gates both the falling edge and ResumeDiscoveryIfIdle. HandleDiscoveredAsync's "don't re-grab the device being flashed" check became a set membership test, so it is correct for concurrent flashes too.

I also deferred the re-grab to the last lease for that path, which your finding implies but does not state: reopening the HID handle while another write to the same device is still in flight would fight it, and that is a worse outcome than the flag being wrong.

Why this is safe given I cannot flash hardware to check it. The change is a strict generalisation: with one outstanding lease the counted set is a set of one, the total goes 0→1→0, and every branch takes the same path it took before. Behaviour on the only reachable code path is unchanged, so this cannot regress the flash I am not permitted to bench-test.

Evidence, not assertion. I reverted ReleaseFlashSlot to the single-shared-slot semantics you described and re-ran:

Failed  BootloaderWatcherFlashStateTests.Disposing_one_of_two_overlapping_leases_leaves_the_flash_in_progress
Failed!  - Failed: 1, Passed: 56, Total: 57

Restored → Passed! - Failed: 0, Passed: 57, Total: 57.

That new test is the disposal-ordering coverage you said was missing: it disposes the first of two leases and asserts IsFlashInProgress is still true, HID discovery is still stopped, no falling edge was raised, and BeginHoldCount has not moved (the re-grab was correctly deferred) — then disposes the second and asserts all four flip. The pre-existing overlap test now also disposes both leases rather than leaking them.

Divergence from upstream 52257bdf is deliberate and commented at the field declaration, including why upstream's single slot is right for upstream's caller set.

Gate re-run on e5e6418: desktop 270 warnings, iOS Release iossimulator-arm64 271, 0 errors, 198 distinct first-party warning sites — all unchanged from the merge base measured in the same worktree with the same cleared-obj discipline. packages.lock.json untouched.

One unrelated improvement in the same push: the "pause is in place" test now reads the guard from inside the transport drain (parking the discovery task and using the fact that an async method runs synchronously to its first suspension). Reading it after the drains could not distinguish "flag raised before the drains" from "flag raised after them", which is the property the fix actually claims. Falsified by moving the assignment below the drains — 1 red, restored green.

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

@tylerkron
tylerkron force-pushed the fix/168-pause-discovery-for-hid-flash branch from e5e6418 to 107956a Compare September 1, 2026 23:05
@tylerkron

Copy link
Copy Markdown
Contributor Author

Rebased onto main — #172, #173 and #174 all merged while round 2 was running, so the branch and every number in the body were measured against a three-PR-old base.

No conflicts. The one shared-state risk I went looking for is absent: #172's new ConnectionManagerTeardownTests also sets DeviceBeingUpdated, and this PR's tests set it on the process-wide ConnectionManager.Instance, which xUnit could have run in parallel — but #172 builds its own manager through the internal test constructor and never touches Instance, so there is no race.

Re-measured on the new base 7b65eb0, same cleared-obj discipline, both sides built in this worktree: desktop 270 → 270 warnings, iOS Release iossimulator-arm64 271 → 271, 198 distinct first-party warning sites on both sides (only two pre-existing ConnectionDialogViewModel.cs warnings shifted line numbers). Suite 122 → 140, 0 failures. packages.lock.json untouched.

Round 2 was clean on e5e6418 (Bugs 0 / Rule violations 0 / Skill insights 0, round 1's finding struck through as Resolved, 0 unresolved review threads), but that is no longer the head — requesting a fresh round on 107956a.

@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 107956a

…HID flash

Discovery was paused for an *auto* firmware update but not for a *manual* HID
bootloader flash, because a manual flash never sets
ConnectionManager.DeviceBeingUpdated and so is invisible to
IsFirmwareUpdateInProgress. Two ways the bus came back live mid-write:

- Another device's auto-update finishing while the user was in the firmware
  dialog picking a .hex restarted serial AND WiFi discovery unconditionally,
  leaving both running when they hit Upload.
- ConnectHid's finally restarted discovery the moment the dialog closed, even
  though the flash lease is released asynchronously and can outlive the dialog
  (closing it mid-flash only cancels).

The pause is now a three-reason guard, IsDiscoveryPausedForFirmware: a
coordinator auto-update, an open HID firmware dialog, or a bootloader write
still in flight on the app-global watcher. Every reason can hold discovery
down on its own, and each one calls the restart when it clears, so whichever
clears last is the one that actually brings discovery back.

Also fixes an ordering bug in BootloaderWatcher.ResumeAfterFlashAsync: it
cleared _flashingPath before awaiting the target's re-grab, so the watcher
advertised "flash over" while the HID handle was still being reopened. It now
clears in a finally after the re-grab — late enough to cover the handoff,
unconditional enough that a failed re-grab cannot strand the marker set.

Ported from daqifi-desktop 52257bdf (their issue #777).

closes #168
…ing lease

Qodo round 1, one High finding, accepted.

PrepareFlashAsync hands out an independent lease per call but the watcher
recorded a single _flashingPath, so disposing the first of two overlapping
leases cleared the shared state, resumed HID discovery and released the
connection dialog's guard while the second write was still on the bus.

Not reachable through today's UI — the only caller, FirmwareDialogViewModel,
sits behind a modal opened from another modal — but IsFlashInProgress is now a
published contract read by a component that holds no lock, so the state has to
mean "any lease outstanding", not "the most recent one".

_flashingPath becomes a counted set of paths plus a single-field total for the
lock-free reader. The re-grab is likewise deferred to the last lease for that
path: reopening the handle while another write to the same device is in flight
would fight it. Behaviour is identical for one flash, which is the only case
that is reachable today.

Also, per the same finding, the existing overlap test only proved a second
prepare was allowed; it now disposes both leases, and a new test covers the
disposal ordering it did not.

Plus: the "pause is in place" test now reads the guard from *inside* the
transport drain, using the fact that an async method runs synchronously up to
its first suspension. Reading it after the drains could not tell "flag raised
before the drains" from "flag raised after them", which is the property the
fix actually claims.
…very pause

Qodo round 3, one Medium finding, accepted.

PrepareFlashAsync raises the flash marker before awaiting the target hold's
release — deliberately, so an auto-update ending during that release cannot
see a gap. But a throwing ReleaseAsync exits without returning a lease, and
the lease is the only thing that ever clears the marker. IsFlashInProgress
then stays true forever.

That was survivable before this PR, when a stranded marker only paused the
watcher's own HID discovery. It is not now: the connection dialog keys its
serial and WiFi discovery on the same flag, so a single failed release would
leave the whole bus unscanned for the life of the process, over a flash that
never started.

The release is now wrapped: on failure the marker is rolled back, discovery
resumes if nothing else needs it paused, and the exception is rethrown — so
the caller still sees the failure, and no rising edge is ever announced for a
flash that did not begin.
@tylerkron
tylerkron force-pushed the fix/168-pause-discovery-for-hid-flash branch from 107956a to c60e1ac Compare September 1, 2026 23:21
@tylerkron

Copy link
Copy Markdown
Contributor Author

Round 3 triage — 1 new finding, accepted and fixed in c60e1ac

First, a process note on my own reading: I checked this round's comment shortly after it appeared and read Bugs (0). Qodo edits that comment in place, and two minutes later the same comment on the same SHA read Bugs (1). A count read mid-review is not a result — I nearly called a clean round on it.

"Failed preparation strands pause" — accepted, and it is worse than it looks because of this PR.

PrepareFlashAsync raises the flash marker before awaiting the target hold's release. That ordering is deliberate: an auto-update of another device ending during that release must not see a gap. The cost you identified is real — a throwing ReleaseAsync exits without returning a lease, and the lease is the only thing that ever clears the marker.

The stranding itself pre-dates this PR (upstream sets _flashingPath at the same point). What this PR changes is the blast radius: before, a stranded marker only paused the watcher's own HID discovery. Now the connection dialog keys its serial and WiFi discovery on IsFlashInProgress, so a single failed release would leave the entire bus unscanned for the life of the process — over a flash that never started. Failing loudly is acceptable; failing quietly and permanently is not. That escalation is mine, so the fix is mine.

Fix. The release is wrapped. On failure: roll the marker back via ReleaseFlashSlot, call ResumeDiscoveryIfIdle() so discovery comes back if nothing else needs it paused, and rethrow so the caller still sees the failure. The rethrow also means the rising-edge raise below it is never reached — so a flash that never began never announces a start, and no subscriber is left waiting for a falling edge it will not get.

Evidence. Removed the rollback (kept the rethrow) and re-ran:

Failed  BootloaderWatcherFlashStateTests.A_preparation_that_fails_to_release_the_hold_does_not_strand_the_pause
Failed!  - Failed: 1, Passed: 140, Total: 141

Restored → green. The test drives it through the fake hold's new ReleaseGate, asserts IsFlashInProgress is false afterwards, that discovery came back, and that no edge was raised in either direction.


Separately, at the dispatcher's prompting I re-validated against a main that moved five times during this review. Branch is now rebased onto 4a8c338 and everything re-measured there:

  • Desktop 250 → 250 warnings, iOS Release iossimulator-arm64 251 → 251, 0 errors. Distinct first-party warning sites 182 on both sides (only two pre-existing ConnectionDialogViewModel.cs warnings shifted line numbers). Both sides built in this worktree with all four obj/ dirs cleared.
  • dotnet test 133 → 152, 0 failures.
  • packages.lock.json untouched.

Three interactions checked explicitly, all clear — #172's OnDeviceConnectionLost exemption (a manually flashed board is a sitting bootloader, never in ConnectedDevices, so that handler never runs for it), #172's tests vs. this PR's use of the ConnectionManager singleton (they use the internal test ctor, no shared state), and #177's twelve deleted types (referenced by none of this). Details in the body.

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

…lls back

Qodo round 4, one finding, accepted — and a consequence of round 3's own fix.

IsFlashInProgress is polled, not only subscribed to. Between PrepareFlashAsync
raising the marker and the rollback that a throwing ReleaseAsync now triggers,
it reads true, and the connection dialog's discovery guards can refuse a
restart on the strength of it — a coordinator auto-update ending inside that
window is exactly the case. Rolling the marker back silently left that refusal
with nothing to retry it, so serial and WiFi discovery stayed stopped over a
flash that never began: the same permanent stranding round 3 set out to
remove, moved one level up.

The edge is now announced from a finally, so it fires on the failure path as
well as the happy one. No rising edge is announced when the preparation throws
(the rethrow precedes it), so this is an unpaired falling edge — which is
precisely the signal a subscriber needs: whatever refused you is gone, ask
again.
@tylerkron

Copy link
Copy Markdown
Contributor Author

Round 4 triage — 1 finding, accepted and fixed in cffbed6

"Failed preparation loses restart" — accepted. This one is a consequence of the round-3 fix, not of the original PR, and it is the better catch of the two.

Round 3 added a rollback so a throwing ReleaseAsync could not strand the marker. What I missed is that IsFlashInProgress is polled, not only subscribed to. Between the increment and the rollback it reads true, and ConnectionDialogViewModel.IsDiscoveryPausedForFirmware — which holds no lock — can refuse a restart on the strength of that transient value. A coordinator auto-update of another device ending inside that window is exactly the case. Rolling back silently left that refusal with nothing to retry it, so serial and WiFi discovery stayed stopped: the same permanent stranding round 3 set out to remove, moved one level up.

Worth being precise about the reachable severity, rather than overselling it: in today's only flow the caller is FirmwareDialogViewModel, running inside RunWithHidFlashQuiescedAsync, so _hidFirmwareDialogOpen is still true and the dialog's own exit path retries the restart when the modal closes. The window is real, the published contract is genuinely violated, and the backstop is incidental rather than designed — so I fixed it rather than leaning on it.

Fix. The edge is announced from a finally, so it fires on the failure path as well as the happy one. On failure no rising edge was announced (the rethrow precedes it), which makes this an unpaired falling edge — and that is exactly the right signal for a subscriber whose restart was refused: whatever refused you is gone, ask again. RaiseFlashInProgressChanged guards each subscriber individually, so raising from a finally cannot mask the in-flight exception.

Evidence. Suppressed just the rollback edge (if (flashStarted && _outstandingFlashes > 0)) and re-ran:

Failed  BootloaderWatcherFlashStateTests.A_preparation_that_fails_to_release_the_hold_does_not_strand_the_pause
Failed!  - Failed: 1, Passed: 151, Total: 152

Restored → 152/152. That test now asserts the edge as well as the state, and the dialog half of the same story is already pinned by The_pause_clears_when_the_bootloader_write_finishes_after_the_auto_update — the two compose into "a refused restart is always retried".

Gate on cffbed6, both sides at 4a8c338, all four obj/ dirs cleared each side: desktop 250 → 250 warnings, iOS Release iossimulator-arm64 251 → 251, 0 errors, distinct first-party warning sites 182 on both sides. dotnet test 152/152. packages.lock.json untouched. CI on the previous head also had Android green, which is the head this Mac cannot build.

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

@tylerkron

Copy link
Copy Markdown
Contributor Author

Qodo-clean, CI green — ready for review

Verified independently on head cffbed6534d209fb7646c5710129be6f52d149c5 (base 4a8c338, current main): all 7 checks green individually (6 success + Claimed bumps are actually applied correctly skipped), mergeable: MERGEABLE / CLEAN, and both Qodo surfaces clear — summary comment re-read in place (Bugs 0 / Rule violations 0, updated to cffbed6) and all 3 inline qodo-code-review threads isResolved: true, none outstanding.

Extra checks beyond the boilerplate:

  • Pipeline is current, not stale. Newest .github/workflows/ commit on main is 8977e80 (19:15Z); this run started 23:31Z, so it includes the Test and from-scratch-lock steps.
  • Tests actually ran: Passed! - Failed: 0, Passed: 152, Total: 152, matching the claimed 133 → 152.
  • No collision with fix(export): the app stated its channel-order rule three times and only one was right #180, which branched from the same base and also adds tests: zero shared files, and the new test types are in distinct namespaces (Daqifi.Avalonia.Tests.Device.Firmware, Daqifi.Avalonia.Tests.ViewModels) with distinct class names — no namespace, class-name, or shared-fixture overlap.

@tylerkron
tylerkron added this pull request to the merge queue Sep 2, 2026
Merged via the queue into main with commit 222cd15 Sep 2, 2026
7 checks passed
@tylerkron
tylerkron deleted the fix/168-pause-discovery-for-hid-flash branch September 2, 2026 01:40
tylerkron added a commit that referenced this pull request Sep 2, 2026
This branch deletes `AbstractStreamingDevice.Write(string)` and updated every
test double that overrode it — every one that existed at 4a8c338, its branch
point. #178 landed on main afterwards and added a fourth:
`ConnectionDialogFirmwarePauseTests.TestDevice`, whose
`public override bool Write(string command) => throw new NotSupportedException();`
had nothing left to override once the two were merged.

Git called the merge CLEAN because it is — textually. The two changes touch
disjoint files and only fail together at compile time, so the merge queue was
the first thing to see it: CS0115 in the `Test` step of run 33671611557.

The double is a placeholder for `ConnectionManager.DeviceBeingUpdated`; the
six tests that construct it never write, send or connect. Deleting the override
leaves it satisfying the base's two remaining abstract members
(`ConnectionType`, `SendMessage`), exactly like the three doubles this branch
already updated. #178's subject — discovery staying paused across a manual HID
flash — is untouched.

Also merges current origin/main into the branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(firmware): device discovery is not paused for a concurrent manual HID flash

1 participant