fix(update): give the mute update process a voice, and stop two safety states going unrecorded (#5244) - #5247
fix(update): give the mute update process a voice, and stop two safety states going unrecorded (#5244)#5247sanity wants to merge 3 commits into
Conversation
…hem (#5244) `freenet update` ran with no tracing subscriber at all. `set_logger` is called only on the node path (`run_node`), so `freenet_main`'s `Update` arm installed nothing and every `tracing::warn!` / `error!` in the installer was a no-op — not just `debug!`. The supervisor invokes it as `freenet update --quiet`, so any site whose only output was a `warn!` plus a `!quiet`-gated `eprintln!` produced nothing anywhere on the automated path. The consequence that matters: an update can install with crash-loop rollback protection never armed, and nothing records it. The trigger is an unwritable or full state directory, which is a fleet condition rather than a freak event. #4073 exists to stop a bad release bricking the fleet, and this is a path where it is off and invisible. Found while investigating #5232, which turned out to be a misreading of the journal caused by this same gap. ## The subscriber `set_cli_logger` installs a stderr-only subscriber at WARN for the `Update` arm. Two traps decided its shape, and both produce a fix that looks done and changes nothing: * Passing a log dir — the natural copy-paste from `run_node` — sets `use_file_logging`, routing everything into the rolling log files. systemd captures stdout/stderr, NOT those files; that asymmetry is the whole of #5232. Every `warn!` would start "working" while the journal stayed exactly as blind. * With no log dir, `init_tracer` still falls through to stdout unless `FREENET_LOG_TO_STDERR` happens to be set. So `init_cli_stderr_tracer` is explicit rather than configuration- dependent: stderr, always. Diagnostics belong there, it leaves the command's `println!` output on stdout, and systemd records both. It is additive rather than a flag on `set_logger`. That function serves the node, where output MUST keep reaching the log files (`freenet service report` collects them, and on Windows nothing captures stdout). Adding a "log to stderr instead" switch to the node's only logging call would put a mis-settable lever on the path that must not lose files. WARN, not INFO: this runs on every non-clean stop, so an INFO default would flood the journal of exactly the crash-looping node whose journal we need to read. ## The two that must never be quiet Both are states where the machinery designed to stop a bad release bricking the fleet is silently OFF, so both get an unconditional `eprintln!` as well — belt-and-braces, surviving any later refactor that drops the subscriber. This follows `handle_post_stop`'s existing precedent, which is why its crash and rollback lines were visible in the field while the rest of the file was not. * "failed to snapshot the known-good binary" — the update is about to land with no rollback target. * "installed the update but FAILED TO ARM crash-loop rollback" — it landed and the marker is absent, so the post-stop hook finds nothing to count. Deliberately only those two. A general de-quieting would leave `--quiet` meaning nothing; the separation this restores is that `--quiet` controls chattiness and never whether a safety event is recorded. The third, "auto-update is LOCKED OUT after repeated failed installs", was `debug!` — compiled out of release builds — and the loud once-per-process warning in `check_if_update_available` is reachable only from the peer-signal triggers, so on a node whose peers all share its version nothing said it would never update again. Now warn + stderr, once per process, mirroring that existing `LOCKOUT_WARNED` pattern: the condition persists until an operator acts, so one line per boot is the right cadence rather than a repeat every 6h forever. Every other `tracing::warn!` in the update path — the blocked-version refusal, the rate-limit exits, the install-gate counter — becomes visible through the subscriber without touching its call site. That is the point of fixing the class rather than the instances. ## Testing * `the_update_process_emits_tracing_to_stderr` runs the real binary at `RUST_LOG=trace` and asserts stderr is non-empty and stdout is empty. Before the fix both were empty whatever `RUST_LOG` said. Not network- dependent despite `--check` reaching for GitHub: the HTTP stack traces its connection attempts on any outcome, including offline. * `the_update_process_is_quiet_when_nothing_is_wrong` pins the WARN default, so nobody quietly promotes it to INFO. * `the_update_arm_installs_the_cli_logger_and_not_a_file_logger` pins the wiring, which is the difference the runtime tests cannot see: a log-dir subscriber would still show output on a terminal. Scraped across files (test in `tests/`, source in `src/bin/`) so it cannot be satisfied by its own assertion strings — the self-matching failure mode in `.claude/rules/bug-prevention-patterns.md`. Verified by hand as well: `RUST_LOG=trace freenet update --check --quiet` now writes 24 lines to stderr and 0 to stdout, and is silent at the default level. Refs #5244, #5232 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
…#5244) `get_update_failure_count_at` matched `Err(_) => 0`, which catches every read error and not just `NotFound`. A counter file made unreadable — EACCES, EIO, a directory in its place — therefore read as "no failures" and silently reset the #3934 lockout, the mechanism that bounds the exit-42 → failed-install → restart loop. That is a fail-OPEN on safety equipment, and the doc comment directly above the function already promised the opposite: Present but unparseable → MAX_UPDATE_FAILURES (defensive: if the counter file has been truncated or corrupted we must NOT silently reset the lockout — that would be an amplification vector for any process that can partially overwrite the file, defeating the #3934 fix). Only the parse path was defended. Making the file unreadable defeats the lockout more easily than partially overwriting it, which is the attack the comment names. It also fails open at precisely the wrong moment: an unwritable or full state directory is what makes installs fail in the first place, so the counter is unreadable exactly when it is needed. Combined with the two silent paths fixed in the previous commit, one bad state directory disables all three guards at once — rollback never arms, the counter can neither persist nor read back, and nothing says so. `read_github_poll_bucket_at`, twenty lines away in the same file, already makes this distinction and explains why ("Any other read error (permissions, etc.) is treated as corrupt => deny, conservatively, rather than granting a free poll"). Same file, same intent, opposite behaviour; this brings the counter into line with its sibling and with its own docs. Testing - `an_unreadable_failure_counter_does_not_reset_the_lockout` puts a directory where the counter file belongs and asserts both the count and `should_attempt_update_at`. A directory rather than a chmod because it fails for root too, so the test cannot quietly degrade into a no-op in a container. - `an_absent_failure_counter_reads_as_no_failures` keeps the legitimate case working, so a fresh node is not born locked out. Refs #5244, #3934 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
Rule Review: mostly solid, one misattached doc commentRules checked: git-workflow.md, code-style.md, testing.md Warnings
Info
Rule review against |
…e claim Two independent reviews of #5247. Findings, in the order they matter. **A regression this PR introduced.** Making the counter read fail-closed was right for the GATE — an unreadable counter keeps auto-update suppressed while the condition lasts, and heals by itself because the gate re-reads every time. But `record_update_failure_at` fed that same defensive answer back through `+ 1`, so one transient EIO on a flaky mount would have written 4 to disk and locked auto-update off PERMANENTLY. That is worse than the fail-open it replaced. The decision is now `next_failure_count`, a pure function that writes nothing when the counter is unreadable, with a unit test over all four cases. Extracting it is what makes the case testable at all: the alternative is contriving a filesystem that reads one way and writes another. **A test that could not fail.** `the_update_process_is_quiet_when_nothing_is_wrong` claimed to pin WARN-vs-INFO, but nothing on the `--check` path logs at INFO, so promoting the level would have left it green. Deleted. The level is pinned in the source scrape instead, where it is a fact about the code rather than about which paths happen to log. **Two tests that would have gone red on a network blip.** Both ran `update --check`, which reaches api.github.com — so they depended on the network being up AND GitHub not rate-limiting a shared CI egress IP, and they spent real quota twice per run, which #5102 went to some trouble to conserve. They now seed the installer's GitHub cooldown file, which makes `probe_latest_tag` defer with exactly one known WARN before any HTTP. Deterministic and offline. That fixture also fixes a subtler problem: the old assertion looked for TRACE output from the HTTP stack, which `release_max_level_info` compiles out entirely. It passed only because CI builds debug, and would have failed on the build we ship. A WARN survives both. **A claim that is not true on two platforms.** "This fixes the class" is systemd-only: `spawn_update_command` nulls stdio, so under the Windows wrapper and the macOS tray path the new subscriber output and both unconditional messages go to the bit bucket. Its doc comment also said nulling was "harmless on macOS/Linux because `--quiet` already suppresses all output", which this PR made false. Comment corrected, with the shape of the real fix recorded (point stderr at the wrapper log; the Windows constraint is about INHERITED invalid handles, not about needing null). NOT fixed here: it needs a log dir threaded through five call sites plus a stdio change on the code path that has caused three os-error-6 incidents, and I cannot exercise Windows or macOS from here. Left for a follow-up rather than shipped untested. **Escape sequences into the journal.** The CLI tracer went through `init_stdout_tracer`'s `pretty` layer, which colours unconditionally and splits each event across several lines. journald stores bytes verbatim, so that wrote ANSI into the destination this change exists to reach. It now builds its own compact layer with ANSI gated on `stderr().is_terminal()`, with a test asserting no escapes reach a pipe. **A comment promising something the code did not do.** `set_cli_logger` said failing to install a subscriber must not fail the update, while the only realistic failure — a subscriber already installed — panicked inside `init_stdout_tracer`. The new layer returns the error instead, so the promise now holds. Also from review: the lockout message named a remedy that does not work when the counter is unreadable (`freenet update` cannot clear what it cannot read), so it now names the file and `--force` too; the `LOCKOUT_REPORTED` static moved next to the branch it guards; the source pin strips comments so a comment ABOUT the call cannot satisfy it; and the tests use `CARGO_BIN_EXE_freenet` rather than a hand-rolled `target/debug` lookup that would happily assert against a stale binary — the failure mode this test exists to prevent. Refs #5244 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
…e claim Two independent reviews of #5247. Findings, in the order they matter. **A regression this PR introduced.** Making the counter read fail-closed was right for the GATE — an unreadable counter keeps auto-update suppressed while the condition lasts, and heals by itself because the gate re-reads every time. But `record_update_failure_at` fed that same defensive answer back through `+ 1`, so one transient EIO on a flaky mount would have written 4 to disk and locked auto-update off PERMANENTLY. That is worse than the fail-open it replaced. The decision is now `next_failure_count`, a pure function that writes nothing when the counter is unreadable, with a unit test over all four cases. Extracting it is what makes the case testable at all: the alternative is contriving a filesystem that reads one way and writes another. **A test that could not fail.** `the_update_process_is_quiet_when_nothing_is_wrong` claimed to pin WARN-vs-INFO, but nothing on the `--check` path logs at INFO, so promoting the level would have left it green. Deleted. The level is pinned in the source scrape instead, where it is a fact about the code rather than about which paths happen to log. **Two tests that would have gone red on a network blip.** Both ran `update --check`, which reaches api.github.com — so they depended on the network being up AND GitHub not rate-limiting a shared CI egress IP, and they spent real quota twice per run, which #5102 went to some trouble to conserve. They now seed the installer's GitHub cooldown file, which makes `probe_latest_tag` defer with exactly one known WARN before any HTTP. Deterministic and offline. That fixture also fixes a subtler problem: the old assertion looked for TRACE output from the HTTP stack, which `release_max_level_info` compiles out entirely. It passed only because CI builds debug, and would have failed on the build we ship. A WARN survives both. **A claim that is not true on two platforms.** "This fixes the class" is systemd-only: `spawn_update_command` nulls stdio, so under the Windows wrapper and the macOS tray path the new subscriber output and both unconditional messages go to the bit bucket. Its doc comment also said nulling was "harmless on macOS/Linux because `--quiet` already suppresses all output", which this PR made false. Comment corrected, with the shape of the real fix recorded (point stderr at the wrapper log; the Windows constraint is about INHERITED invalid handles, not about needing null). NOT fixed here: it needs a log dir threaded through five call sites plus a stdio change on the code path that has caused three os-error-6 incidents, and I cannot exercise Windows or macOS from here. Left for a follow-up rather than shipped untested. **Escape sequences into the journal.** The CLI tracer went through `init_stdout_tracer`'s `pretty` layer, which colours unconditionally and splits each event across several lines. journald stores bytes verbatim, so that wrote ANSI into the destination this change exists to reach. It now builds its own compact layer with ANSI gated on `stderr().is_terminal()`, with a test asserting no escapes reach a pipe. **A comment promising something the code did not do.** `set_cli_logger` said failing to install a subscriber must not fail the update, while the only realistic failure — a subscriber already installed — panicked inside `init_stdout_tracer`. The new layer returns the error instead, so the promise now holds. Also from review: the lockout message named a remedy that does not work when the counter is unreadable (`freenet update` cannot clear what it cannot read), so it now names the file and `--force` too; the `LOCKOUT_REPORTED` static moved next to the branch it guards; the source pin strips comments so a comment ABOUT the call cannot satisfy it; and the tests use `CARGO_BIN_EXE_freenet` rather than a hand-rolled `target/debug` lookup that would happily assert against a stale binary — the failure mode this test exists to prevent. Refs #5244 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
f2b07cd to
d45fe5a
Compare
Review response — two lenses, one real regression, two tests that could not failSkeptical and code-first, both blind. Between them they found something I had introduced, two assertions that could not fail, and a claim in the body that was true only on Linux. All addressed in The regression I introducedMaking the counter read fail-closed was right for the gate — an unreadable counter suppresses updates while the condition lasts, and heals by itself because the gate re-reads every time. But The decision is now I also had an unsupported claim in the doc comment and the body: "an unwritable state dir is what makes installs fail, so the counter is unreadable exactly when it is needed." Not true — EROFS/ENOSPC break writes; reads still succeed or report Two tests that could not fail
Two tests that would have gone red on a network blipBoth ran That fixture fixed a subtler problem too: the old assertion looked for TRACE output from the HTTP stack, which A third test in that revision, on A claim that was true only on Linux"This fixes the class" is systemd-only. Not fixed here, deliberately: it needs a log dir threaded through five call sites plus a stdio change on the code path behind three prior os-error-6 incidents, and I cannot exercise Windows or macOS from here. Shipping that untested is exactly the kind of change that has bitten this file before. Body now states the limitation; #5244 tracks it. Also fixed
Not taken
[AI-assisted - Claude] |
Problem
freenet updateran with no tracing subscriber at all.set_loggeris called only on the node path (run_node), sofreenet_main'sUpdatearm installed nothing and everytracing::warn!/error!in the installer was a no-op — not justdebug!. The supervisor invokes it asfreenet update --quiet, so any site whose only output was awarn!plus a!quiet-gatedeprintln!produced nothing anywhere on the automated path.The consequence that matters: an update can install with crash-loop rollback protection never armed, and nothing records it. The trigger is an unwritable or full state directory, which is a fleet condition rather than a freak event. #4073 exists to stop a bad release bricking the fleet, and this is a path where it is off and invisible.
Found while investigating #5232, which turned out to be a misreading of the journal caused by this same gap. Full analysis in #5244.
Solution
1. Give the process a voice
set_cli_loggerinstalls a stderr-only subscriber at WARN for theUpdatearm.Two traps decided its shape, and both produce a fix that looks done and changes nothing:
run_node— setsuse_file_logging, routing everything into the rolling log files. systemd captures stdout/stderr, not those files; that asymmetry is the whole of Post-update probation commit is invisible in the journal, and has no regression test #5232. Everywarn!would start "working" and the journal would stay exactly as blind.init_tracerstill falls through to stdout unlessFREENET_LOG_TO_STDERRhappens to be set.So
init_cli_stderr_traceris explicit rather than configuration-dependent: stderr, always.It is additive rather than a flag on
set_logger. That function serves the node, where output must keep reaching the log files (freenet service reportcollects them, and on Windows nothing captures stdout). Adding a "log to stderr instead" switch to the node's only logging call would put a mis-settable lever on the one path that must not lose files.WARN, not INFO: this runs on every non-clean stop, so an INFO default would flood the journal of exactly the crash-looping node whose journal we need to read.
This fixes the class — on systemd. Every other
tracing::warn!in the update path (the blocked-version refusal, the rate-limit exits, the install-gate counter) becomes visible without touching its call site.It does NOT reach the field on Windows or the macOS tray path.
spawn_update_commandnulls stdio on every platform (load-bearing on Windows: the wrapper has calledFreeConsole(), and inheriting invalid handles failsspawn()with os error 6). So under those supervisors the new subscriber output and both unconditional messages go to the bit bucket. Its doc comment also claimed nulling was harmless because "--quietalready suppresses all output", which this PR makes false; that comment is corrected here with the shape of the real fix recorded. The fix itself is deliberately NOT in this PR: it needs a log dir threaded through five call sites plus a stdio change on the code path behind three prior os-error-6 incidents, and I cannot exercise Windows or macOS from here. Tracked in #5244.2. Two messages that must never be quiet
Both are states where the machinery designed to stop a bad release bricking the fleet is silently OFF, so both also get an unconditional
eprintln!— belt-and-braces, surviving any later refactor that drops the subscriber. This followshandle_post_stop's existing precedent, which is why its crash and rollback lines were visible in the field while the rest of the file was not.Deliberately only those two. A general de-quieting would leave
--quietmeaning nothing. The separation this restores:--quietcontrols chattiness, never whether a safety event is recorded.A third, "auto-update is LOCKED OUT after repeated failed installs", was
debug!— compiled out of release builds — and the loud once-per-process warning incheck_if_update_availableis reachable only from the peer-signal triggers, so on a node whose peers all share its version nothing said it would never update again. Now warn + stderr, once per process, mirroring that existingLOCKOUT_WARNEDpattern.3. A fail-open on the lockout (second commit, reviewable on its own)
get_update_failure_count_atmatchedErr(_) => 0, catching every read error and not justNotFound. A counter made unreadable — EACCES, EIO, a directory in its place — read as "no failures" and silently reset the #3934 lockout.The doc comment directly above the function already promised the opposite and named the threat ("an amplification vector for any process that can partially overwrite the file"). Only the parse path was defended; making the file unreadable defeats it more easily than partially overwriting it.
read_github_poll_bucket_at, twenty lines away in the same file, already makes this distinction and explains why. Same file, same intent, opposite behaviour.Note the states that reach this arm are anomalous ones — a chmod'd or wrong-owner file, a directory in its place, a failing mount — not the read-only/full state dir case, where reads still succeed or report
NotFound. Treating them as fully-failed is self-healing: the gate re-reads every time and recovers the moment the file is readable again.Why these compound
One bad state directory disabled all three guards at once: rollback never arms (silent), the failure counter can neither persist nor read back (silent, and fails open), and the line that would say the node is locked out was compiled out. The whole sequence ran without one line in the journal.
Testing
All deterministic and offline. The runtime tests seed the installer's GitHub cooldown file, so
probe_latest_tagdefers with exactly one known WARN before any HTTP — no network dependency, no quota spent, and the assertion is on a WARN rather than a TRACE so it survivesrelease_max_level_info(an earlier revision asserted TRACE and would have passed in CI's debug build while failing on the build we ship).a_warning_from_the_update_process_reaches_stderrRUST_LOGsaid.the_update_process_does_not_write_ansi_escapes_to_a_pipethe_update_arm_installs_the_cli_logger_at_warn_and_not_a_file_loggeran_unreadable_failure_counter_does_not_reset_the_lockoutan_unreadable_counter_is_never_persisted_as_a_countan_absent_failure_counter_reads_as_no_failuresMutation-tested; each kills the guard that names it:
Updatearmset_loggerinstead ofset_cli_logger(the trap)Err(_) => 0on the counterThe second is the one worth noting: it is the implementation a reasonable person would write, it compiles, it makes
warn!"work" — and both guards reject it, because the runtime test finds the output on stdout instead of stderr and the pin sees the wrong call.The source pin is scraped across files (test in
tests/, source insrc/bin/) so it cannot be satisfied by its own assertion strings — the self-matching failure mode documented in.claude/rules/bug-prevention-patterns.md.Also verified by hand:
RUST_LOG=trace freenet update --check --quietnow writes 24 lines to stderr and 0 to stdout, and is silent at the default level.Not in scope
#5244 lists further sites in the same class that this PR does not touch — the install-gate threshold moment, the rate-limit denies with no log at any level, the swallowed counter write, and the
!quiet-gated unsigned-release allowance. Most become visible through the subscriber; the ones with no logging at all still need their own lines.Closes #5244
[AI-assisted - Claude]