fix(update_validation): poll system health instead of broken state wait - #206
Conversation
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
wait_for_system_running() filtered on the property name instead of the value and used an uncached property, so it never observed a transition: it hung until the validation timeout. Replace it with a bounded poll that fails fast on a degraded state or crash-looping units and reports the cause in the reboot reason. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The auto-restart point-in-time check flagged any Restart= unit as crash-looping the moment the 2s poll caught it mid-restart, even for a single transient failure (e.g. aziot-identityd-precondition with RestartSec=5). That could roll back a good update. Crash loops are now detected only by restart count while the unit is not active. The threshold defaults to 3 and is overridable via CRASH_LOOP_RESTART_THRESHOLD, following the existing timeout() env-var pattern. UnitHealth no longer carries sub_state since it is unused. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
- collect_unit_health: include non-service units so a degraded state caused by e.g. a failed mount names the unit in the reboot reason; a per-unit D-Bus error no longer aborts the validation and counts as 0 restarts - rename omnect_health__*.sh to omnect_health_*.sh (single underscore) - normalize indentation in omnect_health_crash_loop.sh and document why its auto-restart branch is more sensitive than the update validation rule Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
|
FYI @mlilien |
There was a problem hiding this comment.
Pull request overview
This PR improves firmware update validation by replacing a broken “wait for running” mechanism with a polling-based system health check that can fail fast on degraded systems or crash-looping services. It also extends the standalone healthcheck/ scripts with new checks (system running, crash-loop, timesync, coredumps), updates documentation, and bumps the crate version.
Changes:
- Replace
wait_for_system_running()withwait_for_system_healthy(deadline)and introduce crash-loop / degraded detection insrc/systemd. - Document the new validation behavior (system health deadline + crash-loop detection) and update README references.
- Add/adjust healthcheck scripts and configuration, and bump version to
0.45.0.
Reviewed changes
Copilot reviewed 11 out of 16 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| src/twin/firmware_update/update_validation.rs | Switch validation to wait for “healthy” with an internal deadline. |
| src/twin/firmware_update/update_validation.md | Document system health polling deadline and crash-loop semantics. |
| src/systemd/mod.rs | Implement wait_for_system_healthy() plus unit/crash-loop evaluation and tests. |
| README.md | Mention healthcheck/ scripts and the new crash_loop check. |
| healthcheck/omnect_service_log.sh | Update script invocation name to omnect_health_services.sh. |
| healthcheck/omnect_service_log_analysis.json.template | Update referenced healthcheck script name. |
| healthcheck/omnect_health_timesync.sh | Add timesync healthcheck. |
| healthcheck/omnect_health_system_running.sh | Add system-running healthcheck. |
| healthcheck/omnect_health_services.sh | Add service-exit-log analysis healthcheck script. |
| healthcheck/omnect_health_crash_loop.sh | Add crash-loop detection healthcheck. |
| healthcheck/omnect_health_coredumps.sh | Add coredumps healthcheck. |
| healthcheck/omnect_health_checks.json.template | Update naming convention text; add crash_loop entry. |
| healthcheck/omnect_health_checks.json | Add crash_loop entry to default checks. |
| healthcheck/omnect_health_check.sh | Update invoked check-script prefix to omnect_health_. |
| Cargo.toml | Bump crate version to 0.45.0. |
| Cargo.lock | Bump locked crate version to 0.45.0. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The health deadline was derived from the configured validation timeout, but validation only starts after authentication, so its clock starts later than the outer timeout's. Any slower authentication let the generic timeout win the race, and the reboot reason lost the cause. A configured timeout below the 60s floor could not produce a descriptive reason at all. Use the time that is actually left instead, and check the deadline only while the system is still starting, so a health confirmation in progress cannot be cut short by a small deadline. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
fe74b5b to
be42145
Compare
NRestarts has no time window and keeps its value while a unit recovers, so on long uptime an occasional restart accumulated to the threshold and turned the rating red whenever the check happened to sample the unit between restarts. A unit that gave up restarting is a failed unit and is already reported by the system-running check. Also show pending jobs there, so a state other than "running" always comes with its reason. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The double underscore separates the single check scripts from the main script, which is the only one a user is expected to call. The new crash loop check follows that convention now too. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 10 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/systemd/mod.rs:105
wait_for_system_healthy(Duration::ZERO)can still sleep and take ~4s to return OK because the confirmation loop always requiresHEALTHY_CONFIRMATION_POLLSpolls, regardless of the provided deadline. This makes thedeadlineparameter surprising (0 does not mean “no waiting”) and can still allow the outer validation timeout to win when the remaining time is very small.
healthy_polls += 1;
if healthy_polls >= HEALTHY_CONFIRMATION_POLLS {
return Ok(());
}
src/systemd/mod.rs:180
CRASH_LOOP_RESTART_THRESHOLDcurrently accepts0, which makes every non-active service matchn_restarts >= thresholdand therefore forces validation to fail on most systems. Treat0as invalid and fall back to the default (or require>= 1).
match value.parse::<u32>() {
Ok(value) => threshold = value,
Reaching the crash loop threshold takes threshold x RestartSec, which is much longer than the confirmation polls take, so a system was validated as healthy while a service was still on its way into a crash loop. Keep polling while a service has restarted and is not active; if the deadline passes without the threshold being reached, accept the system, because a rollback needs evidence. Also derive the health deadline from a monotonic instant: the outer validation timeout is monotonic, and a clock step during boot must not shorten the health deadline against it. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
A threshold of 0 matched every non-active service, so any inactive oneshot unit was rated a crash loop and validation always rolled back. Treat 0 like an unparseable value and keep the default. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
JoergZeidler
left a comment
There was a problem hiding this comment.
Review of the system health validation.
Verification on my side: cargo fmt -- --check is clean. cargo clippy --features bootloader_grub and cargo test --features mock could not be run here, the azure-iot-sdk-sys build script aborts because azure-iot-sdk-dev.pc is not in PKG_CONFIG_PATH. So no compile or test evidence from me.
Two things I could not check from this repo:
- the claim that
TEST_REBOOT_REASON_CHECK_EXTRA_INFOin the omnect-os CI does an exact match on the two extra info strings - whether the meta-omnect recipe installs
healthcheck/*.shby wildcard or by an explicit list
The first three inline comments can end in a rollback of a good update, the rest are smaller.
A single observation of degraded or of a crash loop rolled the device back, while a healthy verdict needed several consecutive polls. One poll landing between a unit failing and something restarting it was enough to lose a good update, the same false positive that the restart count rule removed for the auto-restart check. Both verdicts now need the same number of consecutive observations, and the deadline ends the wait for every state, so alternating failures cannot keep it polling. Only a unit in a restart cycle counts, instead of any unit that is not active: systemd keeps the restart count on a unit that gave up, which the degraded state already reports. Further review findings: reject a crash loop threshold of 0, which matched every unit at once; read NRestarts without a property cache and only for units in a restart cycle; cap the reported unit list, since it ends up in a fixed size pmsg record; keep the state strings and the poll decision in one place; report a failure to list services separately from the findings. The decision loop is now testable without a system bus. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
92f57fe to
4ec4b2e
Compare
JoergZeidler
left a comment
There was a problem hiding this comment.
Second round on 3db1212 and 4ec4b2e. All findings from the first round are addressed; I replied in each thread and resolved them, except the packaging one, which is still open.
Verification: cargo fmt -- --check is clean. cargo clippy --features bootloader_grub and cargo test --features mock still cannot run here (azure-iot-sdk-dev.pc is not in PKG_CONFIG_PATH), so the new watch_system_health tests are read, not run.
Five points below, the first two are the ones that matter.
At the deadline the current poll alone picked the outcome, so a system alternating between degraded and healthy was decided by whichever poll happened to be last. Count the observations of the whole wait instead: an unhealthy verdict needs as many as a confirmed one, only not consecutive. Without any healthy observation a single unhealthy one decides, since nothing speaks for the update then. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
A single failed poll propagated out of the wait, so a bus hiccup while the system was still booting rolled the update back on the first try. A failed poll is now no observation: it breaks the confirmation chain and the wait keeps polling. Only the same number of failures in a row that a verdict needs ends the wait. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
…restart 'activating' is also a normal start, so a service with a restart history that was started by a timer or a dependency was rated as restarting and kept the check polling until the deadline. Take the sub state into account, which list_units() already returns: only there systemd tells a pending restart from a start. This is the condition the crash loop health check script uses, plus the sub state newer systemd reports for a queued restart. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The variable was removed on the last line of the test, so a failing assert left it behind. Tests share the process, so the next test read it and failed somewhere else. A guard restores the previous value in Drop. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
JoergZeidler
left a comment
There was a problem hiding this comment.
Third round on b31c1bb, 9029c26, 4b6a329 and 2367e7a. All five findings from the last round are implemented, replied to in the threads and resolved, and the recipe question is answered.
Verification: cargo fmt -- --check is clean. cargo clippy --features bootloader_grub and cargo test --features mock still cannot run here (azure-iot-sdk-dev.pc is not in PKG_CONFIG_PATH), so the new code and tests are read, not compiled.
The first one follows from your own note about SubState and is the only one that can still roll back a good update.
The typed bindings reject a unit state they do not know, and one such unit fails the whole ListUnits reply. A state that stays that way makes every poll fail, which rolls a healthy update back. Read the unit list through a local proxy with string states, so an unknown state stays uninteresting instead of fatal, and name the states this check rates as constants. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
A last observation the check does not rate, e.g. degraded whose failed unit already vanished, or a system that is stopping, bypassed the tally and failed the validation even after healthy polls. A healthy observation the tally did not confirm now still passes; only a wait that never saw the system up keeps the last observation as its verdict. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The reboot reason is written from the outermost error message, so the bus error stayed in the source chain and extra_info only said that polling failed. The cause is part of the message now. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The tally counted unhealthy observations across both causes but kept only the last one, so two degraded observations plus one crash loop reported the crash loop. Each cause is counted on its own now and the reported one is the cause with the most observations. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
The check only accepted the 'auto-restart' sub state, so on a systemd that reports the restart job as queued it missed exactly the loop that fails an update validation. Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
JoergZeidler
left a comment
There was a problem hiding this comment.
Fourth round on f9ee5cc through fb6ec7c. All six findings from the last round are implemented, replied to in the threads and resolved.
Verification: cargo fmt -- --check is clean and bash -n on the health check script passes. cargo clippy --features bootloader_grub and cargo test --features mock still cannot run here (azure-iot-sdk-dev.pc is not in PKG_CONFIG_PATH), so the new proxy, ListedUnit and the string patterns are read, not compiled.
Two small points left, nothing that blocks.
One observation outside this PR: src/systemd/unit.rs:82 still filters on the typed ActiveState from list_units_by_patterns, so the same failure class lives on there. The pattern limits the reply to the wifi commissioning units, so the blast radius is much smaller, and it is not new here. Worth a follow up, not part of this change.
…ll outcome Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
|
The |
JoergZeidler
left a comment
There was a problem hiding this comment.
All findings from the four review rounds are addressed and the threads are resolved.
What I verified locally on 5e476fd: cargo fmt -- --check is clean, bash -n on the health check script passes, ListedUnit matches the ListUnits signature, and the SubState/ActiveState gaps that made the typed bindings brittle no longer reach the poll.
What I could not verify: cargo clippy --features bootloader_grub and cargo test --features mock do not run in my environment (azure-iot-sdk-dev.pc is not in PKG_CONFIG_PATH), so the code and the tests are read, not compiled. Please make sure both pass before merging.
One follow up outside this PR: src/systemd/unit.rs:82 still filters on the typed ActiveState from list_units_by_patterns, the same failure class this PR removed from the health poll, with a much smaller blast radius.
## Summary - new ods recipe 0.45.0 (generated with cargo-bitbake) - ods 0.45.0 replaces the broken update-validation system-state wait with a polling health check that fails fast on degraded state or crash-looping services (omnect/omnect-device-service#206) - install the new omnect_health_crash_loop.sh health check script - update the iot-hub-device-update patch to fix another crash revealed by the CI tests for this change --------- Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
Summary