fix(control): a failed watchdog re-render no longer reports as applied (#395) - #403
Merged
Merged
Conversation
#395) `install_watchdog` ended on `systemctl enable ... || true`, so it returned 0 whatever had happened above it, and both apply paths called it under `|| true` — which also suppresses `set -e` for the whole dynamic extent of the call. A unit write that could not land therefore aborted nothing and surfaced nowhere. A control `/apply` changing `watchdog_interval_min` recorded `applied` while the rig kept its old cadence, with nothing to detect the divergence. Unmasking the call sites alone would not have fixed it, and would have made things worse: with the `|| true` removed, the first failing `sudo tee` ERR-traps `control_apply` out before it writes any terminal status, leaving the receiver serving the previous outcome forever — the failure #276 already fixed for an unreadable rollback backup. So the honest return value has to come from the function itself. - `install_watchdog` records the steps that can genuinely fail — the two unit writes, `daemon-reload`, and the removal in the disabled branch — and returns that status. The `|| true` on enable/disable of a possibly-absent timer stays. - The restart-free fast path propagates it and bails before stamping provenance: a change that did not take effect must not be recorded as the config in force. The caller routes it to the same full-pipeline rollback that path's own comment already promises for a fast-path failure. - `apply` captures it, still runs the remaining `install_*` steps (abandoning the reconcile would strand more units than the one that failed), warns, and returns non-zero. - `_control_do_apply` gates on `apply`'s exit status, which it discarded. - The recorded `reason` names the watchdog instead of a hashrate that never dropped, and when the rollback's re-apply hits the same failure it says so rather than claiming a liveness check that never ran. Every pre-existing reason string is byte-identical when no such cause is known. Scope note: the disabled branch's `sudo rm -f` guard is one step beyond the three the issue names. A timer that could not be REMOVED is the same lie in the other direction, so it is included; it is a two-line revert if unwanted. The issue's alternative — a doctor check comparing the rendered timer's `OnUnitActiveSec` against config.json — is deliberately NOT implemented here. Verified: `bash tests/run.sh` -> 1853 passed, 0 failed (baseline 1826; 27 new assertions, no existing test changed). Every new refusal mutation-tested: 10 mutants, all killed, each by an assertion specific to it — including the two unit writes, which are asserted through a per-file `tee` stub so they cannot alibi each other. `shfmt -i 4 -d` clean, `bash -n` clean. NOT run locally: `shellcheck` (its historical peak on a file this size has OOM-killed sessions on this box) and the Docker e2e. CI covers both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5tRnh79JUNv6q2q4F5qRh
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #395.
What was wrong
install_watchdogended onsystemctl enable ... || true, so it returned 0 whatever had happenedabove it — and both apply paths called it under
|| true, which additionally suppressesset -eforthe whole dynamic extent of the call. A unit write that could not land therefore aborted nothing and
surfaced nowhere. A control
/applychangingwatchdog_interval_minrecordedappliedwhile the rigkept its old cadence, with nothing anywhere to detect the divergence.
The issue's literal suggestion — deleting the
|| trueat the call sites — would not have fixedthis, and would have made it worse. Confirmed with a repro, not by reading: with
|| truein place,a function whose first command fails and which then runs a bare
falsestill returns 0. Remove themask and the first failing
sudo teeinstead ERR-trapscontrol_applyout before it writes anyterminal status, leaving the receiver serving the previous outcome forever — the exact failure #276
already fixed for an unreadable rollback backup. The honest return value has to come from the
function itself.
What changed — four seams
install_watchdogrecords the steps that can genuinely fail (the two unit writes,daemon-reload, and the removal in the disabled branch) and returns that status. The|| trueonenable/disable of a possibly-absent timer stays deliberate.
_control_do_apply_fastpropagates it and bails before stamping provenance — a change thatdid not take effect must not be recorded as the config in force. That re-render is the fast path's
entire effect. The caller then routes to the same full-pipeline rollback that path's own comment
already promises for a fast-path failure.
applycaptures it, still runs the remaininginstall_*steps (abandoning the reconcile wouldstrand more units than the one that failed), warns with an actionable message, and returns non-zero.
_control_do_applygates onapply's exit status, which it discarded outright.The recorded
reasonnow names the watchdog instead of a hashrate that never dropped, and when therollback's own re-apply hits the same failure it says so rather than asserting a liveness check that
never ran. Every pre-existing reason string is byte-identical when no such cause is known.
Second caller — please read this before merging
applygaining a non-zero return affects two callers, not one. I swept every call site:_control_do_apply(rigforge.sh:4199) — the intended one.rigforge.sh:5960), which callsapply "$@"bare underset -Eeuo pipefailwith
trap on_err ERRarmed. Sosudo rigforge applywith unwritable watchdog units now printsthe specific warning, then aborts through
on_errand exits non-zero, where it previously exited 0.I believe that is the correct behaviour — an apply that did not apply should not exit 0, which is the
whole point of the issue — but it is a user-visible change beyond the control path, so it is called
out here and documented in the
applyrow ofdocs/operations.md. Say the word if you want the CLIpath left on its old exit code.
Scope
One step beyond the three seams the issue names: the disabled branch's
sudo rm -fis guarded too. Atimer that could not be removed is the same lie in the other direction. Two-line revert if unwanted.
The issue's alternative — a
doctorcheck comparing the rendered timer'sOnUnitActiveSecagainstconfig.json— is deliberately not implemented here. It is a detector for drift from any cause,which is a separate piece of work from making this path honest.
What was RUN
Graded by who ran it, because the two are not worth the same.
Re-derived on this branch, just now:
bash tests/run.sh→ 1853 passed, 0 failed, exit 0 (baseline 1826; 27 new assertions, noexisting test changed by
applygaining a non-zero return).shfmt -i 4 -dclean,bash -nclean on both changed files.install_watchdog'sreturn "$rc"to a barereturn 0→ suite exit 1, killed byfour assertions, all of them the Control /apply reports applied even when the watchdog re-render failed #395 unit-write ones. The
.serviceand.timerwrites failseparately, which is the point: an earlier version of these tests broke both writes with one
unwritable dir, so reverting either guard left the suite green. The per-file
teestub(
WDF_TEE_FAIL) is what discriminates them. The discriminator has to be the fixture, not theassertion.
_control_do_apply's|| return 1back to discardingapply's status → suite exit 1,killed by three different assertions (
_control_do_apply fails when apply fails,a failed apply short-circuits the liveness wait,the reason says the re-apply hit the same failure).Inherited from the session that wrote the tests, and NOT re-derived here: the full mutation
sweep was 10 mutants, all reported killed. Its harness lived in that session's scratchpad and is
gone, so I re-ran only the two above rather than restate a number I cannot reproduce. The remaining
eight are unverified by me.
What was NOT run locally
shellcheck— its historical peak on a file this size has OOM-killed whole sessions on thebox this was built on, taking the tmux scope with it. Deferred to CI deliberately.
Both have since reported on this PR:
Lint (shellcheck + shfmt)pass,End-to-end (Docker)pass. All nine checks are green and
mergeable / mergeStateStatusre-queried separately asMERGEABLE / CLEAN— a tick count is not a mergeability claim in this project.🤖 Generated with Claude Code
https://claude.ai/code/session_01E5tRnh79JUNv6q2q4F5qRh