Flash directly via flashcp when sysupgrade's verify-mount fails#191
Conversation
|
what new format? Is OpenIPC changing format? |
|
No new format — and no, OpenIPC isn't changing anything. That was bad wording in my description, sorry for the confusion. XZ-compressed squashfs is what OpenIPC images already use (the published ssc338q images — RunCam WiFiLink, generic, apfpv — are all XZ). Nothing about the image side is new or unusual. The problem is on the other side: the kernel already running on the device you're flashing. Since opening this I've traced the actual root cause, and it's worse than "the mount fails" — so I've raised a fix upstream in the firmware itself: OpenIPC/firmware#2220. Three things are wrong with that verify-mount, all in its failure path:
#2220 fixes all three at the source (verify before the first write, bound the mount with a timeout, warn instead of aborting), and keeps the SoC check intact — that's what the mount is really there for. So this PR is not about a format change, and it isn't really the fix anymore either — #2220 is. What this one still buys you is the devices already in the field: they're running the current I've also just pushed a fix to this branch for a flaw the same investigation exposed in my own probe: it assumed the mount always fails, when it can hang — so an unbounded probe would have hung in exactly the case it exists to survive. It's now bounded on both sides, and a mount that doesn't answer counts as unmountable and takes the flashcp path. Happy to reword the description to drop the "new image" phrasing that caused this. |
mikecarr
left a comment
There was a problem hiding this comment.
Please rework this before merge.
Branch/build process first: PRs for this repo should target dev first, not master. The README and docs/development.md list the expected local checks, especially:
dotnet build Companion.Desktop/Companion.Desktop.csproj -c Release
dotnet test
GitHub Actions remains the release/source-of-truth path for official artifacts, but feature/fix work should be validated into dev before any promotion to master.
Specific issues I found while checking this against current dev:
-
This does not stack cleanly after #190. Applying this PR on top of
dev + #190fails inCompanion/Services/SysUpgradeService.cs. Please rebase/rebuild this branch on currentdevafter #190 and keep #190's failure propagation behavior. -
As written,
PerformSysupgradeAsyncstill catches exceptions, logs/progresses them, and returns normally. That means direct-flash or sysupgrade failures can still be reported as success by the UI unless #190's rethrow behavior is preserved. -
The mount probe command uses
timeout 45 mount ... || mount .... If the timeout path kills or fails the first mount, this immediately runs a second unbounded mount. That can hang in the exact condition this fallback is meant to avoid. Please change this to usetimeoutwhen available, otherwise fall back to a bare mount only when thetimeoutcommand is missing, not after every mount failure.
The underlying firmware issue this PR targets is plausible, and the partition-name lookup plus size checks are the right direction. Because this writes raw MTD partitions with flashcp, it needs to be stacked on the failure-reporting fix and have the probe timeout fixed before it is safe to merge.
|
Follow-up on the raw MTD / Companion does already use
The normal uploaded firmware upgrade path currently delegates kernel/rootfs flashing to OpenIPC Before this is safe to merge, I think the fallback needs the same kind of safeguards as the existing restore flow:
This is another reason I would keep this PR separate from #190, retarget/rebase it onto current |
The local-file firmware flash shells `sysupgrade --force_ver -n -z`, which loop-mounts the new rootfs to verify it before writing. On a device whose running kernel lacks the squashfs compressor of the new image (commonly XZ), that mount fails with "mount: ... Invalid argument" and sysupgrade aborts -- but only after it has already flashed the kernel, leaving a half-upgraded unit that still boots the old rootfs. The user sees no clear failure and the device stays on its old firmware. Probe the exact same loop-mount before running sysupgrade: - if the running kernel can mount the rootfs, run sysupgrade unchanged (no behavior change for the common case); - if it cannot, write the kernel and rootfs straight to their MTD partitions with flashcp (a raw write needs no mount), erase the settings overlay to mirror `sysupgrade -n`, and reboot. Partitions are looked up by name from /proc/mtd (reusing the project's existing flashcp/flash_eraseall conventions) and size-checked before writing, so we never write the wrong or an oversized partition. Adds unit tests for the /proc/mtd parser. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The probe assumed sysupgrade's verify-mount always fails cleanly. It does not
always fail — it can block forever.
Observed on an SSC338Q air unit: sysupgrade printed
Update rootfs from /tmp/rootfs.squashfs.ssc338q
and never emitted another byte. In sysupgrade's do_update_rootfs() everything
between that echo and the flash write is a silent losetup+mount, so a mount that
blocks produces exactly that signature: kernel flashed, rootfs never written,
device left half-upgraded, and the GUI frozen with no error. (do_update_kernel()
has no mount, which is why the kernel writes fine and streams progress.)
An unbounded probe would therefore hang in precisely the case this fallback
exists to survive. Bound it twice:
- device side: wrap the mount in `timeout`, falling back to a bare mount if the
applet is missing. Best-effort only — SIGTERM cannot free a mount wedged in
uninterruptible (D) state.
- client side: bound the await on the wall clock. A CancellationToken cannot do
this, because ExecuteCommandWithResponseAsync runs the blocking SSH.NET
RunCommand inside Task.Run, whose token only prevents the delegate from
starting; once running, cancelling it does nothing.
A probe that does not answer in time now counts as NOT mountable and takes the
flashcp path, rather than handing the device to a sysupgrade that would only
wedge on the very same mount.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLuRBpBJzbd6Uba5U7u7Qy
24a7244 to
9b4240c
Compare
|
Thanks — that's a fair bar to set, and I agree with it: raw MTD writes in the normal upgrade path deserve the same care as the restore flow. I've retargeted and rebased this onto Going through your list, I think we're closer than the description made it look — most of it is already in the diff, so let me point at the code rather than just assert it: Read Map by partition name, not hard-coded assumptions — Validate that kernel/rootfs targets are unambiguous and present — if either name is missing we abort before writing anything: if (!partitions.TryGetValue("kernel", out var kernelPartition) ||
!partitions.TryGetValue("rootfs", out var rootfsPartition))
throw new InvalidOperationException(
"Could not find 'kernel' and 'rootfs' partitions in /proc/mtd; aborting direct flash to avoid writing the wrong partition.");There's also a size check — an image larger than its partition is refused rather than allowed to run into the neighbouring one — and a Fail loudly if the layout cannot be verified — every one of those paths throws. Make sure exceptions propagate so the UI never reports success after a failed fallback — this one is genuinely better now that #190 has landed. The outer Keep the fallback clearly scoped to the verify-mount failure case — it's only reachable from the Agreed on keeping it separate from #190, and that's how it's structured. One note on validation: Worth saying plainly: I'd rather this PR became unnecessary than see it merged for its own sake. The actual bug is in Happy to make any further changes you'd like, or to reword the description that caused the original confusion. |
mikecarr
left a comment
There was a problem hiding this comment.
Thanks for rebasing this onto dev and for adding the /proc/mtd parser tests. I re-reviewed the branch locally against current dev. Validation passes on my side too:\n\n- dotnet test -v minimal -clp:ErrorsOnly: 63/63 passed\n- dotnet build Companion.Desktop/Companion.Desktop.csproj -c Release -clp:ErrorsOnly: passed after the usual Avalonia telemetry filesystem rerun\n\nI still do not think this is safe to merge yet. Two blocking issues remain in the direct-flash path:\n\n1. The direct flash commands can hang forever even when flashcp succeeds.\n\n In FlashImageDirectlyAsync, both flashcp calls use ExecuteCommandWithProgressAsync with disableTimeout: true and no custom isCommandComplete predicate. That helper's default completion predicate only looks for sysupgrade-style strings ("Unconditional reboot" / "Arguments written"), and a normal flashcp command does not disconnect the SSH session. With timeout disabled, the loop has no way to decide that flashcp completed, so the first flashcp can leave Companion waiting forever and never proceed to rootfs/reboot.\n\n Related: ExecuteCommandWithProgressAsync reports timeout/errors via progress text and returns; it does not throw. For raw MTD flashing we need command exit/failure to be explicit and propagated, not just displayed. Please either run these through a command API that gives an exit status and throw on non-zero, or add a reliable sentinel/exit-code wrapper plus a finite timeout and failure propagation.\n\n2. The mount probe still has the unbounded second mount path.\n\n The generated command is still effectively:\n\n timeout 45 mount ... || mount ...\n\n That means if timeout exists and the first mount times out or fails, the shell immediately starts a second bare mount. The client-side Task.WhenAny stops waiting after ~65s, but the remote command can still be running in the background while Companion starts direct flashcp. That is exactly the situation this probe is supposed to avoid.\n\n Please only use the bare mount as a fallback when the timeout command itself is missing, not after any mount failure/timeout. Something shaped like command -v timeout && timeout ... || mount ... still needs care because the || branch will also run for mount failure; it has to branch on timeout availability separately.\n\nThe overall approach is closer now: retargeting to dev, stacking on #190, partition-name lookup, size checks, and parser tests are all the right direction. But because this path writes raw MTD partitions, the command completion/failure semantics and the probe timeout behavior need to be correct before merge.
Addresses the two blocking issues from the OpenIPC#191 re-review. 1. Direct flashcp could hang forever and never surface failures. FlashImageDirectlyAsync ran flashcp through ExecuteCommandWithProgressAsync with disableTimeout:true and the default completion predicate, which only matches sysupgrade's "Unconditional reboot"/"Arguments written". flashcp prints neither and does not drop the SSH session, so the read loop had no way to finish; and the helper reports errors via progress text without throwing. Extract FlashPartitionAsync, which appends `; echo MARKER$?` to carry flashcp's exit status back over the shell stream, completes the moment that marker (with digits) is parsed, keeps a finite timeout, and throws on a non-zero code or on no marker at all (timeout / lost connection). The echoed command line ("MARKER$?", no digits) is skipped so it can't be mistaken for completion. 2. The verify-mount probe could still spawn an unbounded second mount. `timeout N mount ... || mount ...` ran a bare, un-timed mount on any mount failure/timeout, which keeps running server-side after the client stops waiting. Branch on the applet instead: use `timeout N` when available and a bare mount only when the applet is missing, so exactly one mount runs. dotnet test: 63/63 passed. dotnet build Companion.Desktop -c Release: 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jy7T4DbzZHLqwX5F5xPoGM
|
Thanks for the careful re-review @mikecarr — both blocking issues are fixed in 53a992e. 1. Direct flash could hang / swallow failures. The two flashcp calls no longer go through
2. Unbounded second mount in the probe. Replaced if command -v timeout >/dev/null 2>&1; then M="timeout N"; else M=""; fi
if $M mount -t squashfs -o loop,ro '…' "$d"; then echo RUBY_MOUNT_OK; …Exactly one mount runs — bounded when Validation on my side:
Not yet re-tested against physical hardware — flagging that since this is the raw-MTD path. |
mikecarr
left a comment
There was a problem hiding this comment.
Re-reviewed the updated head (53a992e) against current dev. The two prior blockers are addressed in code now.\n\nValidated locally:\n\n- dotnet test -v minimal -clp:ErrorsOnly: 63/63 passed\n- dotnet build Companion.Desktop/Companion.Desktop.csproj -c Release -clp:ErrorsOnly: passed after the usual Avalonia telemetry filesystem rerun\n\nSpecific fixes checked:\n\n- direct flashcp now uses a finite-timeout FlashPartitionAsync helper, parses an explicit RUBY_FLASHCP_EXIT marker, and throws on non-zero/no marker instead of silently continuing\n- the mount probe now branches on timeout availability and no longer runs timeout mount || bare mount after every mount failure\n- PR remains targeted to dev and stacked on the #190 failure-propagation fix\n\nI am comfortable merging this into dev from a code review standpoint. Remaining caveat: the raw-MTD fallback path still deserves physical-device validation before promoting dev to master/release.
Problem
The local-file firmware flash runs
sysupgrade --force_ver -n -z, which loop-mounts the uploaded rootfs to verify it before writing it. On devices whose currently-running kernel doesn't include the squashfs decompressor used by the new image (typically XZ), that verification mount fails:sysupgradethen aborts — but it has already flashed the kernel by that point, so the device reboots into a half-upgraded state (new kernel, old rootfs) and stays on its old firmware. To the user the flash just "didn't take," with no obvious reason.This happens with perfectly valid images — it depends only on whether the old running kernel happens to include XZ squashfs support, which we don't control and which varies per device.
Fix
Before running sysupgrade, probe the same loop-mount sysupgrade would perform:
sysupgradeexactly as before (no behavioural change for the vast majority of devices).kernelandrootfspartitions directly withflashcp(a raw write needs no mount), erase therootfs_dataoverlay to mirrorsysupgrade -n, and reboot.flashcp/flash_eraseall//proc/mtdparsing are already used elsewhere in Companion (firmware restore, bootloader flash), so this follows existing conventions.Safety
/proc/mtd(kernel,rootfs,rootfs_data); ifkernel/rootfsaren't found, the direct flash aborts rather than guessing a partition.flashcppresence is verified first.sysupgradecannot succeed anyway. If the probe itself can't run, behaviour falls back to attemptingsysupgrade(unchanged).Tests
Adds
SysUpgradeServiceTestscovering the/proc/mtdparser (name→device/size mapping, case-insensitive lookup, CRLF handling, empty/garbage input).Build: 0 errors. Tests: 64 passed.
Complements #190 (which makes a genuinely failed flash report an error instead of false "success"); this PR makes the flash actually succeed on the affected devices.