Skip to content

fix(update_validation): poll system health instead of broken state wait - #206

Merged
JanZachmann merged 25 commits into
omnect:mainfrom
JanZachmann:jz-2026-07-28-wait-for-system-healthy
Jul 31, 2026
Merged

fix(update_validation): poll system health instead of broken state wait#206
JanZachmann merged 25 commits into
omnect:mainfrom
JanZachmann:jz-2026-07-28-wait-for-system-healthy

Conversation

@JanZachmann

@JanZachmann JanZachmann commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • replace wait_for_system_running() (never observed a state transition: wrong property filter + uncached property; hung until the validation timeout) with a polling wait_for_system_healthy()
  • fail validation fast on a degraded system state or crash-looping services; the cause becomes the reboot reason extra info
  • crash loops are flagged by restart count only (default 3, configurable via CRASH_LOOP_RESTART_THRESHOLD) so a single transient restart cannot roll back a good update
  • the health deadline is the remaining validation time minus a safety margin, so the descriptive error always wins the race against the generic validation timeout that decides the reboot reason
  • new crash_loop health check (red when a service loops in auto-restart or has 3+ restarts while not active)
  • update update_validation.md and README; bump to 0.45.0

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

Copy link
Copy Markdown
Contributor Author

FYI @mlilien

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() with wait_for_system_healthy(deadline) and introduce crash-loop / degraded detection in src/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.

Comment thread src/twin/firmware_update/update_validation.rs Outdated
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>
@JanZachmann
JanZachmann force-pushed the jz-2026-07-28-wait-for-system-healthy branch from fe74b5b to be42145 Compare July 30, 2026 09:06
Comment thread healthcheck/omnect_health_check.sh Outdated
Comment thread healthcheck/omnect_health__crash_loop.sh
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 requires HEALTHY_CONFIRMATION_POLLS polls, regardless of the provided deadline. This makes the deadline parameter 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_THRESHOLD currently accepts 0, which makes every non-active service match n_restarts >= threshold and therefore forces validation to fail on most systems. Treat 0 as 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 JoergZeidler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_INFO in the omnect-os CI does an exact match on the two extra info strings
  • whether the meta-omnect recipe installs healthcheck/*.sh by wildcard or by an explicit list

The first three inline comments can end in a rollback of a good update, the rest are smaller.

Comment thread src/systemd/mod.rs Outdated
Comment thread src/systemd/mod.rs
Comment thread src/systemd/mod.rs
Comment thread src/systemd/mod.rs Outdated
Comment thread src/systemd/mod.rs Outdated
Comment thread src/systemd/mod.rs Outdated
Comment thread healthcheck/omnect_health__crash_loop.sh
Comment thread healthcheck/omnect_health__crash_loop.sh
Comment thread README.md Outdated
Comment thread src/twin/firmware_update/update_validation.md Outdated
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>
@JanZachmann
JanZachmann force-pushed the jz-2026-07-28-wait-for-system-healthy branch from 92f57fe to 4ec4b2e Compare July 30, 2026 15:14
@JanZachmann
JanZachmann requested a review from JoergZeidler July 30, 2026 16:15

@JoergZeidler JoergZeidler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/systemd/mod.rs
Comment thread src/systemd/mod.rs Outdated
Comment thread src/systemd/mod.rs Outdated
Comment thread src/systemd/mod.rs
Comment thread src/systemd/mod.rs Outdated
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>
@JanZachmann
JanZachmann requested a review from JoergZeidler July 31, 2026 06:43

@JoergZeidler JoergZeidler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/systemd/mod.rs
Comment thread src/systemd/mod.rs
Comment thread src/systemd/mod.rs Outdated
Comment thread healthcheck/omnect_health__crash_loop.sh Outdated
Comment thread src/systemd/mod.rs
Comment thread src/twin/firmware_update/update_validation.md Outdated
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>
@JanZachmann
JanZachmann requested a review from JoergZeidler July 31, 2026 08:02

@JoergZeidler JoergZeidler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/systemd/mod.rs
Comment thread src/twin/firmware_update/update_validation.md
…ll outcome

Signed-off-by: Jan Zachmann <50990105+JanZachmann@users.noreply.github.com>
@JanZachmann

Copy link
Copy Markdown
Contributor Author

The unit.rs observation from the last round is tracked in #209: active_units_by_patterns() still filters on the typed ActiveState, so the same failure class lives on there. Out of scope here, and the issue notes that the ListedUnit struct and the proxy from f9ee5cc can be reused, since ListUnitsByPatterns has the same return signature.

@JanZachmann
JanZachmann requested a review from JoergZeidler July 31, 2026 08:37

@JoergZeidler JoergZeidler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@JanZachmann
JanZachmann merged commit a34303a into omnect:main Jul 31, 2026
2 checks passed
JanZachmann added a commit to omnect/meta-omnect that referenced this pull request Jul 31, 2026
## 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>
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.

4 participants