Repository navigation
fix(firmware): cancelling a WiFi-only update cancelled a different token than the flash received - #239
Conversation
…celing WIP — these run against the UNCHANGED coordinator. 2 of 4 fail: - Canceling_a_wifi_only_flash_cancels_the_flash times out: the WiFi-only path hands the coordinator a token the coordinator's CancelUpload() does not own, so the flash is never signalled (issue #234). - Cancel_does_not_claim_to_be_canceling_when_nothing_is_cancelable: the status line announces a cancellation with no source to cancel. The other two (combined-update cancel, post-run no-op) pass and exist as regression guards for the fix that follows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…token than the flash received The WiFi-only firmware update created its CancellationTokenSource in DaqifiViewModel and passed the coordinator only the token. The Cancel button routes to FirmwareUpdateCoordinator.CancelUpload, which cancels the coordinator's own _firmwareUploadCts — a field only the combined PIC32+WiFi path ever assigns, and which that path nulls when it finishes. On the WiFi-only path it was null, the ?. swallowed the call, and the flash ran on while the status line read "Canceling firmware update...". Root cause: one piece of state (the cancellation source for the in-flight upload) lived in two places, and the cancel entry point was wired to one of them while one of the two producers wrote the other. Fixed by giving the coordinator sole ownership: - UpdateWifiModuleOnlyAsync creates and disposes the run's source, exactly as UploadFirmwareAsync does; DaqifiViewModel no longer holds one. - BeginUpload/EndUpload make "replace the source" and "mark the host uploading" a single step, so the Cancel button is never enabled over a source that does not exist yet. - CancelUpload gates on the source rather than on the host's uploading flag, so it can no longer announce a cancellation it cannot deliver. - UpdateWifiModuleAsync is now private (nothing outside passes its own token) and it and FlashWifiPackageAsync drop the coreDevice parameter, which was threaded through both and used by neither. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoFix WiFi-only firmware cancellation token ownership
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Code Review by Qodo
1.
|
… the Cancel-vs-Dispose race Qodo, correctly: clearing _firmwareUploadCts before disposing it only protects readers that have not read yet. CancelUpload captures the reference first, so a run finishing in between still left it calling Cancel() on a disposed source. Fixed the way this repo already settled the same race in #26 — by removing the disposal rather than adding a lock around it. Nothing reads the source's WaitHandle or gives it a timer, so there is no unmanaged resource to release, and with no Dispose call a captured reference can only ever signal a live-or-finished source, which is a harmless no-op. BeginUpload's dispose of the previous run's source goes for the same reason. GC reclaims retired sources. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 8fec8d2 |
|
Qodo-clean, CI green — ready for review at Verification behind this note is CI's run on Qodo. Round 1 on Claims re-derived rather than repeated
The disposal removal holds up
One finding, not a blockerNo Avalonia view surfaces the firmware Cancel affordance at all. Across all 31 So the description's "the Cancel button stays enabled" / "wrote … into the UI" overstates today's user-visible impact: the wiring defect and this fix are real at the view-model layer, but the user-facing half is currently unreachable because the control was never ported. That is pre-existing and untouched by this PR — the plumbing here is correct and ready ( It also settles the screenshot question: no screenshot. This PR changes nothing that is drawn (no Also checked
Not merging. |
…ount The ledger's rows 1 and 2 still read as open gaps and its "deliberately not fixed here" note still blocks on #239, which merged this morning. Struck both rows with what actually changed; the 2026-09-04 measurement counts above them are left as recorded, since the file is a dated measurement. Also: the test file's class comment said two writes were moved and two deleted. One of the two was not moved — the run is opened earlier so the write it already made lands inside. Four writes failed the invariant, in three different ways.
Not merging — for review.
closes #234
What was wrong
Cancel a WiFi-module-only firmware update and the status line changes to "Canceling firmware update…", the Cancel button stays enabled — and the flash keeps running to completion. Nothing is cancelled and nothing is logged. A user who believes they stopped the flash and then unplugs the board is unplugging mid-flash.
Closing the window was no escape hatch either: the flash carried on against a torn-down view model.
Why
Two different fields held the same idea — "the cancellation source for the in-flight firmware upload" — and the Cancel button was wired to one of them while one of the two producers wrote the other.
FirmwareUpdateCoordinator._firmwareUploadCtsDaqifiViewModel._firmwareUploadCtsUpdateWifiFirmwareOnlyCancel routes to
FirmwareUpdateCoordinator.CancelUpload(), which cancels the coordinator's field. On the WiFi-only path that field isnull, so the?.swallowed the call. Its guard made the lie complete: it gated on_host.IsFirmwareUploading— a flag the view model had just set — so it passed the guard, wrote "Canceling firmware update…" into the UI, and then cancelled nothing.The fix
Sole ownership, in the coordinator.
UpdateWifiModuleOnlyAsyncis the WiFi-only entry point. It creates the run'sCancellationTokenSourceexactly asUploadFirmwareAsyncdoes.DaqifiViewModelno longer holds one — the field, and its create/dispose/null lifecycle in three places, are deleted.BeginUpload/EndUploadmake "adopt the source" and "mark the host uploading" one step, in that order, so the Cancel button is never enabled over a source that does not exist yet.CancelUploadgates on the source, not on the host's flag. It can no longer announce a cancellation it has no way to deliver. That guard is what made the original bug silent.EndUpload), which is how the repo settled the sameCancel()-versus-Dispose()race in feat(mobile): logged-session plot viewer + delete/delete-all (#7 phase-2) #26. Nothing reads itsWaitHandleor gives it a timer, so there is nothing unmanaged to release, and with noDisposea reference already captured byCancelUploadcan only ever signal a live-or-finished source — a harmless no-op.UpdateWifiModuleAsyncis nowprivate— no caller outside the coordinator hands it a token any more — and it andFlashWifiPackageAsyncdrop thecoreDeviceparameter, which was threaded through both and dereferenced by neither.Both call sites now read the same way: the coordinator opens the run, hands its own token down, and closes the run in a
finally.Verification
Firmware cancellation had no test coverage at all before this — no test in the repo constructed a
FirmwareUpdateCoordinator.Daqifi.Avalonia.Tests/Device/Firmware/FirmwareUpdateCancellationTests.cs(5 tests) is new. It needs no hardware: the cancellation is observed at the coordinator's own injected seams — the download service and the host's WiFi-probe quiesce both park on the token they were handed, so a test asserts on exactly the token the flash received.Run against the unfixed coordinator first. Commit 770031c is the test file as written against unchanged
origin/mainproduct code; 2 of its 4 tests fail there, and they are the two that describe the defect:Canceling_a_wifi_only_flash_cancels_the_flash— fails withTimeoutExceptionafter 5 s. The flash never unwinds, because the token it is running on is never signalled. This is issue Cancel during a WiFi-only firmware update cancels the wrong CancellationTokenSource — the UI says "Canceling" and the flash runs on #234 reproduced.Cancel_does_not_claim_to_be_canceling_when_nothing_is_cancelable— fails: the status line reads "Canceling firmware update…" with no source to cancel.The other two (
Canceling_a_combined_update_cancels_the_flash,Cancel_after_a_wifi_only_flash_has_finished_is_a_no_op) pass onorigin/mainand exist as regression guards for the combined path, which already routed Cancel correctly and must keep doing so now that both paths share one owner.The final file has 5 tests, all passing — the fifth (
A_wifi_only_flash_marks_the_host_uploading_while_it_runs) pins that the coordinator now raises the flag the Cancel button'sCanExecutereads. Three of the five call the new entry point and so cannot be expressed againstmainat all.Gate, at head
8fec8d2:iossimulator-arm64: green.dotnet test: 472 passed, 0 failed.a74933d) in the same worktree, same--no-incrementaldiscipline,third_party/excluded, compared as a set of (file, warning code). Identical on both heads — none added, none removed (65 distinct, desktop and iOS Release alike).packages.lock.jsonuntouched across the repo.No hardware was involved and no firmware was flashed: this is a
CancellationTokenSourcewiring defect, fixed and verified in code.Disclosures
Disposecalls, not by locking. That means it is a structural property of the code — there is no disposed source for a captured reference to touch — rather than something a unit test pins, since deterministically interleavingCancelUploadwithEndUploadwould need a seam that exists only for the test.Cancel_after_a_wifi_only_flash_has_finished_is_a_no_opcovers the observable contract.DaqifiViewModel.Disposenever cancelled the old field and this PR does not add a cancel there. Aborting a firmware flash on window close is a device-safety decision, not a wiring fix, and slipping it in here would be the wrong place for it. Worth its own ticket.IsFirmwareUploadingis no longer settrueand then immediately back tofalse. Nothing observes the transient, and the ordering relative toCancelWifiFirmwareCheck()is unchanged in effect — everything from the command's re-entrancy guard toBeginUpload()runs in one synchronous stretch with noawait, so no dispatcher work can interleave.private, andUpdateWifiModuleAsyncis not a binding target. Nothing in the.axamlrefers to any of it.🤖 Generated with Claude Code