Config: /config/recover stops reporting a write failure the disk has since repaired (F2) - #223
Merged
Merged
Conversation
…a write would land Finding F2 of the 2026-09-10 QA run on 7c96d79: after one genuine write failure the permissions were restored and config.yaml repaired, and three consecutive POST /config/recover calls on the healthy, writable, valid config still answered persisted: false with the stale "Permission denied". The record behind Config::last_write_error() was cleared only by save_values on a successful write, and a config that loads needs no recovery, so the reload the route forces never writes. Config::outstanding_write_failure() now checks the record against the disk: nothing recorded costs no I/O; a config that still does not load keeps its record (the values really are in memory only, and clearing on a writable directory would reopen M9); a config that loads is probed by staging its own bytes through the same path save_values uses and removing the result, never renaming it, so config.yaml, its stamp and its backups are untouched. On Windows a read-only config.yaml also counts as unwritable, because std's rename refuses a read-only destination there; not on unix, where it does not. The record carries a count, so a check never clears a failure recorded while it probed. last_write_error() is the string view of the same checked answer. The route gains run_recovery(&Config) as a test seam, and a config that loads but cannot be written is no longer called "in memory only": it gets its own sentence, from ConfigWriteFailure::NotWritable. persisted stays exactly write_error.is_none(); its doc, and so the OpenAPI contract regenerated here, now names both ways it can be false.
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.
Why
Finding F2 (MEDIUM-LOW) of the 2026-09-10 QA run on merged
main7c96d796, a follow-upto #217. After one genuine write failure — a corrupt
config.yaml,chmod 444on the file andchmod 555on its directory — the permissions were restored and the file repaired, and threeconsecutive
POST /config/recovercalls on that healthy, writable, valid config all stillanswered
persisted: falsewith the stalePermission denied (os error 13)note. Only anunrelated
POST /config/upsertcleared it (deterministic, 3/3).Root cause
The record behind
Config::last_write_error()had exactly one way to be retired: the clear insidesave_values, on a successful write.recover_configinvalidates the cache and reloads — and aconfig that loads needs no recovery, so that reload writes nothing. The clear's own comment
described the resulting state: "Without this the record is permanent, and a config directory that
was briefly unwritable … would make
/config/recoverwarn that changes will not persist for therest of the process's life." It could only ever be reached by a write, and the one route that
reports the record never makes one on a healthy config.
What changed
crates/biorouter/src/config/base.rsConfig::outstanding_write_failure()— the record, checked against the disk as it is now.last_write_error()is now its string view, so every consumer gets the checked answer, not only/config/recover. The rule is clear when every clause of the warning has stopped being true:become — the values in use really are in memory only, and the next reload runs the recovery
whose write retires it. Clearing on a writable directory alone would reopen M9;
probe_config_writedoes everythingsave_valuesdoes that can fail — the same stagingpath, open, lock, write and
fsync, carrying the file's own bytes — and then removes thestaged file instead of renaming it into place. So
config.yamlis never touched: no stampchange for
catalog::spawn_config_watcherto publish as an outside edit, no backup rotation,and no window in which another process's write is reverted by stale bytes. The staging step is
extracted into
stage_config, shared withsave_values, so the check fails exactly where a realwrite would.
config.yamlas unwritable: Rust 1.92'sstd::fs::renamecallsMoveFileExW(MOVEFILE_REPLACE_EXISTING)and falls back toFileRenameInfoExwithoutFILE_RENAME_FLAG_IGNORE_READONLY_ATTRIBUTE, so the renameevery write ends in refuses a read-only destination even when staging succeeds. Not on
unix, where a
0o444file in a writable directory is replaced without complaint — checkingthe file's bits there would bring F2 back for every read-only config.
ConfigWriteFailure(new, re-exported):ValuesInMemoryOnly(M9 — the file is absent orwill not load) vs
NotWritable(the file loads, a write fails now). A probe that still failsre-records the error it just got, so the reason reported is the current one.
WriteFailureRecord { error, recorded }. The check reads it, probes with nolock held, then clears only if
recordedhas not moved — a failure recorded while it probed isone it never tested.
last_write_errorstays a leaf lock: nothing is acquired while it is held,so the
guard→values_read→values_cacheorder is untouched.load()and thesingle-flight gate are not modified; the storm path reaches
save_values, whose staging blockmoved verbatim into
stage_config.crates/biorouter-server/src/routes/config_management.rsrecover_configdelegates torun_recovery(&Config)— a seam for the same reason Config: record the failed write on the backup-restore path, and make /config/recover's answer truthful #217 maderecovery_reportpure: the route readsConfig::global(), the user's real config directory,and the half that depends on the disk can only be tested against a config of its own.
directory stays unwritable now loads, so Config: record the failed write on the backup-restore path, and make /config/recover's answer truthful #217's note — "These values are in memory only … the
next start will recover again" — would be false in three clauses out of four (before this PR it
was emitted there anyway, from the stale record). That state now reads "config.yaml loads, but
it cannot be written right now (…), so a setting changed in this session will not be saved."
persistedis still exactlywrite_error.is_none(); its doc now names both ways it can befalse, and the OpenAPI contract is regenerated for that (doc-only; no shape change).Tests
Seven new in
config::base(plus one Windows-only), three new inroutes::config_management.Fail-before — the three F2 tests, run against unmodified production code (only the
run_recoveryseam and the tests added):The chmod walk-through failed at its step 2 — the repaired-but-unwritable state — so its step 3 (the
literal F2 sequence) was not reached on the old code; the portable test above is F2's step 3, and fails on
call 1 of 3. Step 2's positive assertion (the new sentence is present) was added after this run.
Each guard fails the wrong fix it names — verified against deliberately poisoned trees
(
base.rsrestored from a checksummed copy after each):base.rsa_config_that_still_does_not_load_keeps_its_record_however_writable_its_directory—got None; and #217's owna_backup_restored_over_a_config_that_could_not_be_written_is_recordedanda_write_that_succeeds_clears_a_failure_the_restore_path_recorded, becauselast_write_error()is now the checked answera_config_that_loads_but_still_cannot_be_written_is_reported_as_not_writable,the_check_agrees_with_the_write_it_stands_in_for(got None), and the route'seach_recovery_describes_the_config_as_it_is_now—persistedtrue for a directory still at0o555a_failure_recorded_while_the_check_probed_is_not_the_one_it_clears—left: Nonechecking_whether_the_config_can_be_written_leaves_it_exactly_as_it_was—the stamp must not movethe_check_agrees_with_the_write_it_stands_in_for—Some(NotWritable("Config file I/O failed: config.yaml is read-only"))for a0o444filesave_valuesreplaces without complainta_config_with_nothing_recorded_is_not_checked_at_all—left: 17reads,right: 1Every other test stayed green under each poison. (Under the second, the mid-probe test fails too, but
only because its injection hook lives inside the probe that poison skips — not counted.)
Gates
BIOROUTER_DISABLE_KEYRING=true cargo test -p biorouter --lib -- config:: --test-threads=32, 20×TALLY: 20 passed, 0 failed out of 20(test result: ok. 120 passed; 0 failed; 1 ignored)… cargo test -p biorouter --libtest result: ok. 3801 passed; 0 failed; 2 ignored… cargo test -p biorouter-server --libtest result: ok. 589 passed; 0 failed; 0 ignored… cargo test -p biorouter-server --lib -- routes::config_managementtest result: ok. 18 passed; 0 failedcargo fmt --all -- --check./scripts/clippy-lint.sh✅ clippy::too_many_lines: ok·✅ All baseline clippy checks passed!·✅ Donejust check-openapi-schema(regenerates, then diffs against the commit)✅ OpenAPI schema is up-to-date, rc=0120 is #217's 113 plus the seven new unix-visible tests. The storm tests
(
a_startup_storm_*), #197's retry tests and the deadlock scan(
nothing_on_the_uncached_load_path_reaches_back_through_the_cache, which now also walksstage_configviasave_values) are untouched and green.⚠ Not runnable on this machine, so verified on this PR's CI instead:
a_read_only_config_is_not_called_writable_on_windowsand the#[cfg(windows)]arm ofprobe_config_write.cross-check (x86_64-pc-windows-gnu)compiled both, and thetest (windows-latest)job log showsconfig::base::tests::a_read_only_config_is_not_called_writable_on_windows ... ok— so its premise (a read-only
config.yamlcannot be replaced there) held on a real Windows kernel —beside the portable guards and
a_recovery_after_the_config_was_healed_reports_persisted_with_no_note ... ok(
biorouterlib on Windows:3721 passed; 0 failed). All 16 checks: 15 pass,cross-build-nightlyskipped (schedule/dispatch only).
Runtime verification
A sandboxed
biorouterd agent(BIOROUTER_PATH_ROOTat a scratch directory, its own port andsecret — never the real
~/.config/biorouter), driven over HTTP through the QA sequence:Daemon log for B and C:
Step D is the finding, fixed; step C is the state neither the finding nor #217 measured, and before
this PR it carried the M9 note. The daemon was stopped by its recorded pid (never
pkill, whichmatches other worktrees' daemons) and the sandbox deleted.
Not in scope
biorouter-clireferenceslast_write_error,outstanding_write_failureorrecover_config; its writes reachsave_values, whose stagingmoved into
stage_configunchanged (same open, lock, write, sync, and the handle still droppedbefore the rename), which the
config::suite and the fullbiorouterlib suite exercise.probe_config_writedoes not model a unix immutable flag (chflags uchg,chattr +i) or asticky directory owned by someone else — states where staging succeeds and the rename would
not. There the check can clear a note that is still true; a
set_paramrefused in that statestill returns its error to the caller.
🤖 Generated with Claude Code