Make the in-process staged writes durable across a crash (#58) - #60
Conversation
Atomic replacement is not durability: a rename either happens or does not, but neither it nor the bytes before it are on disk until they are flushed. The three in-process staged writes all published state the program reads back to resume from — trust generations at every start, artifacts at every boot, whatever the root daemon lands — with nothing flushed, and each of them runs during an install or an upgrade, which is when a machine is most likely to be interrupted. The `file.flush()` in `put_file_natively` was worse than an absence: `std::fs::File` has no userspace buffer, so its `Write::flush` is a no-op that reads exactly like the flush that was missing. Each site gets the flush its own situation calls for. The executor syncs after `fchown` and `set_permissions`, since `sync_all` covers that metadata, and syncs `dest.parent()` after the rename rather than the staging directory, which is elsewhere and whose leftover would be inert. The generation engine syncs each material file, then `gen-<n>.tmp` before it is finalised, then the tree root twice — once after the generation rename and again after the `active` swap, so the filesystem cannot commit a symlink ahead of what it names, and because a symlink cannot be flushed itself. `extract_to` syncs each artifact after its hash check while the descriptor is still open, then every directory between an artifact and `dest`, `dest` included and nothing above it. The directory flush goes through one helper returning `std::io::Result<()>`, attaching no context: the executor and the generation engine name the flushed path in their path-carrying I/O variants, and the payload converts bare through its existing `From`, exactly as the rename above it does. The shell transports stage and rename with nothing flushed too, and that is left alone deliberately — a POSIX shell has no `fsync`, so it is a portability decision to settle in its own issue. Closes #58
`activate_generation`'s doc lists what the tree looks like when a failure falls at each point in the sequence, and the flush of `gen-<n>.tmp` added for durability landed between two of those cases without being named by either. The step list put it in step 5, and the "finalised but not yet live" case opens with the rename of step 5 having succeeded, so a caller reading the contract had nowhere to place a flush failure that leaves the temporary directory behind with `active` untouched. That state is the fail-closed one the first case already describes, so say so there and name the flush in step 5 rather than leaving it to be inferred from the step number. Part of #58
The test standing behind the "a failing flush names the path" criterion re-spelled `sync_dir` plus `GenerationError::io` itself, so it asserted a copy of the production line rather than the line, and deleting the flush from `activate_generation` left it green. Inject the fault where the sequence already hands the staging directory out — the validator, which runs after the material is written and before the flush that finalises it — and assert on which path the error names. `rename` reports its destination, so a sequence missing the flush names `gen-1` where this one names `gen-1.tmp`, and the ordering the flush depends on is what the assertion actually reads. Part of #58
|
[Reviewer Round 1] The in-process implementation looks correct to me. One required follow-up is missing. The issue deliberately excludes |
|
[Review Verdict Round 1: NOT_APPROVED] |
|
[Author Round 1] One item, accepted in full. 1. The follow-up issue for
|
|
[Reviewer Round 2] Round 1 is resolved. The required follow-up is now open as #61 and directly covers the intentionally excluded I rechecked the updated diff against #58. The implementation still has the required ordering in all three in-process paths: the native executor syncs the file after ownership and mode changes, then the destination directory after its rename; generation activation syncs the staged files and staging directory before finalisation, then the root after each of the generation and No findings remain. Approved. |
|
[Review Verdict Round 2: APPROVED] |
Suggested squash commitTitle Body |
Atomic replacement is not durability. The three staged writes this crate performs in process each rename their temporary into place with nothing flushed, so a crash or a power loss can undo a write that already reported success — and every one of them publishes state the program reads back to resume from: a trust generation at every start, an artifact at every boot, whatever the root daemon lands. All of them run during an install or an upgrade, which is when a machine is most likely to be interrupted.
Each of the four renames gets the flush its own situation calls for, rather than four copies of one edit:
put_file_nativelysyncs the temporary afterfchownandset_permissions, sincesync_allcovers that metadata and a flush placed where the old no-op sat would leave it unflushed, then syncsdest.parent()after the rename — bound once immediately afterstaging_dirsucceeds andexpected there, because the staging directory is deliberately elsewhere and a leftover temporary is inert. The pre-existingfile.flush()is deleted rather than kept beside it:std::fs::Filehas no userspace buffer, so it read exactly like the flush that was missing while moving no data.activate_generationsyncs each material file inwrite_file_0600, thengen-<n>.tmpas a directory before the rename that finalises it, then the tree root twice — once after the generation rename and again after theactiveswap. One flush at the end is not equivalent: it would let the filesystem commitactiveahead of the generation it names. The symlink swap adds no file flush, since a symlink cannot befsynced at all.Payload::extract_tosyncs each artifact after its hash check passes, while the descriptor is still open, then every directory between an artifact anddest—destincluded, nothing above it, each exactly once.Every directory flush goes through one
pub(crate)helper,durability::sync_dir, returningstd::io::Result<()>and attaching no context: the executor and generation call sites name the flushed path through their path-carryingIovariants, and the payload converts bare through its existingFrom, exactly as therenameabove it does. No new dependency, no public signature change, no new or changed error variant, and no platformcfg.CHANGELOG.mdandPUT_FILE_SCRIPTare untouched.Two things the flushes drag along with them.
activate_generation's doc contract names the staging flush in its step list and places a flush failure in the fail-closed case it belongs to, so a caller reading that contract has somewhere to put the one failure the sequence gained. And the test behind the "a failing flush names the path" criterion injects its fault through the validator — the one hook the sequence hands the staging directory to, running after the material is written and before the flush that finalises it — rather than re-spellingsync_dirplusGenerationError::ioin the test itself, which would assert a copy of the production line instead of the line and stay green if the flush were deleted.Closes #58
Test plan
extract_to's directory set is computed by a named function (publish_dirs) over paths rather than inline in the publish loopdest, an artifact nested several levels deep, two artifacts sharing a parent yielding that parent once, and the walk stopping atdestsync_dirreturns an error rather than succeeding silently when pointed at a path that does not existactivate_generationcall site — deleting the production flush turns the test redcargo testandcargo test --features test-supportpass (429 tests each, run on macOS so the directory flush is exercised on the developer platform)cargo fmt -- --check --config group_imports=StdExternalCratepassescargo clippy --all-targets -- -D warningsandcargo clippy --all-targets --features test-support -- -D warningspassFollow-up
The shell transport's staged write is now tracked by #61. It stages and renames the same way with nothing flushed (
cat > "$tmp"thenmv -f "$tmp" "$dest") and is the pathsecrets.jsonis written by, so the gap is real — but it is deliberately not changed here. A POSIX shell has nofsync, and no shell construct flushes the directorymvpublishes the entry into at all, so it is a portability decision to settle over its own argument rather than in passing.PUT_FILE_SCRIPTis untouched by this PR.