fix: barrier desktop store unlink ordering where directories cannot be fsynced - #952
Conversation
…e fsynced The desktop StagingStore treats op-record-before-sidecar removal ordering as a correctness property, but `fsync_dir` was a deliberate no-op on non-Unix, so on Windows neither the unlink nor the rename was barriered at all. Replace the no-op with a metadata log barrier: create, write and `sync_all` a temp file in the same directory. NTFS journals metadata to a per-volume log flushed as an LSN-ordered prefix, so flushing a transaction issued after the directory-entry change also persists that change. The temp carries the existing `.cbtmp.` prefix, so a crash before its removal leaves debris `ensure_dir` already sweeps. The barrier also covers `atomic_write`, contrary to the premise the old comment recorded: std's Windows `rename` passes `MOVEFILE_REPLACE_EXISTING` alone, never `MOVEFILE_WRITE_THROUGH`, so the rename was unbarriered too. The barrier compiles under `cfg(test)` on every platform, so its behaviour is unit-tested on the Unix CI legs as well as on the Windows one, and the StagingStore ordering test now walks every interruption point in one op's life rather than a single kill point. Closes #665
|
Warning Review limit reached
Next review available in: 48 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
WalkthroughThe change adds durable metadata barriers for non-Unix filesystems, shares synced temporary-file logic, documents StagingStore removal ordering, and tests recovery across four interruption points. ChangesStaging durability
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant StagingStore
participant fsync_dir
participant Filesystem
participant RecoveryTest
StagingStore->>fsync_dir: barrier directory entry
fsync_dir->>Filesystem: write and sync temporary marker
fsync_dir->>Filesystem: remove temporary marker
StagingStore->>Filesystem: remove operation record and sidecar
RecoveryTest->>StagingStore: reopen after interruption
StagingStore-->>RecoveryTest: surviving operations and sidecars
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/desktop-seams/src/fs_util.rs (1)
70-84: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider labeling the write-side barrier failure for the same diagnostic consistency.
remove_file_durablenow wraps afsync_dirfailure with"unlink barrier: {err}"context, so an operator can tell that a durability barrier failed on removal.atomic_write(Line 66) callsfsync_dir(dir)too, but that failure surfaces with no equivalent context. After this change, a barrier failure on write/rename is harder to diagnose than one on unlink, even though both barriers protect the same crash-consistency guarantee.♻️ Proposed fix for labeling consistency
match fs::rename(&tmp, path) { Ok(()) => {} Err(err) => { let _ = fs::remove_file(&tmp); return Err(err); } } - fsync_dir(dir) + fsync_dir(dir).map_err(|err| io::Error::new(err.kind(), format!("write barrier: {err}"))) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/desktop-seams/src/fs_util.rs` around lines 70 - 84, Update the fsync_dir error handling in atomic_write to wrap failures with descriptive write-side barrier context, matching the "unlink barrier: {err}" labeling used by remove_file_durable. Preserve atomic_write’s existing behavior for successful writes and other errors.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/desktop-seams/src/fs_util.rs`:
- Around line 70-84: Update the fsync_dir error handling in atomic_write to wrap
failures with descriptive write-side barrier context, matching the "unlink
barrier: {err}" labeling used by remove_file_durable. Preserve atomic_write’s
existing behavior for successful writes and other errors.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: e6a1671b-674d-413b-a416-8d4b64dbcb57
📒 Files selected for processing (3)
crates/desktop-seams/src/fs_util.rscrates/desktop-seams/src/staging_store.rscrates/desktop-seams/tests/conformance.rs
atomic_write and remove_file_durable protect the same crash-consistency guarantee, so a barrier failure should read the same way in both.
|
Review of
|
Problem
FileStagingStoredocuments removal ordering as a correctness property (desktop-seams hard constraint 5): the op record goes first, its staged-ciphertext sidecar second, so an interruption can only orphan a sidecar — never leave an op record naming a sidecar that is already gone.remove_file_durableenforces that by fsyncing the parent directory after each unlink.fsync_dirwasOk(())on non-Unix. Windows has no directory fsync, so on that platform the ordering was never barriered at all and the store silently reported a durability it had not obtained.The over-claim is wider than the issue recorded. #665 assumed
atomic_writewas still covered on Windows byMOVEFILE_WRITE_THROUGH, per the comment infs_util.rs. That comment is wrong. Rust std's WindowsrenameisMoveFileExW(old, new, MOVEFILE_REPLACE_EXISTING)— write-through is not among the flags (library/std/src/sys/fs/windows.rs,fn rename, checked against the 1.88 sources this workspace builds with). So the rename was unbarriered too, and the fix has to cover both barriers, not just unlinks.Change
fsync_diron non-Unix now callsmetadata_log_barrier: write a byte to a fresh temp file in the same directory andsync_allit. NTFS journals metadata to a per-volume write-ahead log flushed as an LSN-ordered prefix, so flushing a transaction issued after a directory-entry change also persists that change. The temp carries the existing.cbtmp.prefix, so it is invisible tolist_file_namesand a crash before its removal leaves debrisensure_diralready sweeps — the harmless orphan, in line with the same principle the store's ordering rule serves.The barrier is plain
std::fs; the crate is#![forbid(unsafe_code)]and no Win32 call was added. It is compiled undercfg(any(not(unix), test)), so the Windows algorithm is exercised by the Unix CI legs too rather than only by the Windows one.Also in the diff:
atomic_writeandmetadata_log_barriershare awrite_synced_temphelper instead of two copies of create/write/sync_all.remove_file_durableis labelledunlink barrier: …, so the new Windows-only failure mode (the barrier'sFile::createhittingENOSPCor a read-only directory after the unlink already succeeded) is diagnosable rather than surfacing as a barestaging_store remove_operror.fs_util.rsand onFileStagingStoreare corrected; the store's doc now cross-referencesfs_util::fsync_dirrather than restating the platform mechanism.Dependencies
The issue carries no dependency statement. Established from its body and the surrounding desktop slices:
crates/desktop-seamsis a leaf adapter crate. The change is confined to its privatefs_utilmodule and touches no seam trait, no engine code, and no wire format. It is independently mergeable.FileStagingStorestores op bytes verbatim and never parses them, so it cannot know which sidecar an op references and cannot repair the dangerous state at reopen. An ordering barrier is the only enforcement available at this layer.Relationship to #941 and #950
Both are open against the engine-side staged-block lifecycle; this is the desktop seam underneath them. No overlap in files, and the rules do not contradict:
crates/engine/sync/drain.rs) reorders mark-before-release for a leaf so an interruption leaves a marked-and-staged leaf rather than a released-but-unmarked one, and cites this store's hard constraint 5 by name as the rule it is applying. This PR makes that same rule actually hold on Windows, where the barrier it depends on was absent. Strictly reinforcing.staging_store_removal_ordering_leaves_only_a_reclaimable_orphantest name fix: mark a leaf uploaded before releasing its staged bytes #941's body references is preserved.Tests
crates/desktop-seams/src/fs_util.rs:metadata_log_barrier_fails_closed_when_it_cannot_be_established— the revert guard. Restoring the oldOk(())body makes it fail on every platform, verified locally. It pins that the barrier is a real operation with real failure modes, so an unbarriered removal is an error rather than a silent fast path.metadata_log_barrier_leaves_the_directory_as_it_found_it— successive barriers neither collide on a temp name nor accumulate debris, and do not disturb neighbouring files. This one is a residue guard, not a revert guard: it passes against the old no-op too.crates/desktop-seams/tests/conformance.rs:staging_store_removal_ordering_leaves_only_a_reclaimable_orphannow walks all four interruption points of one op's life — bytes staged before the op is journaled, op journaled, op record removed before the sidecar, sidecar reclaimed — reopening the store at each and asserting exactly what survived. ASurvivorsprecondition rejects any kill point that expects an op record without its sidecar, so the forbidden state cannot be encoded into the table.Verification
All exit 0 on macOS (aarch64, Rust 1.88):
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warnings,cargo check --workspace --all-targets,cargo check -p cipherbox-wasm --target wasm32-unknown-unknown --all-targets,cargo test -p cipherbox-desktop-seamsin both debug and--release,pnpm -r --if-present run typecheck,pnpm -r --if-present run test,eslint ..Beyond that, the non-Unix path was forced on locally —
fsync_dirtemporarily rewired sometadata_log_barrierwas the only implementation — and the wholecipherbox-desktop-seamssuite passed against it. That proves the Windows code path compiles and is semantically correct as a barrier-shaped operation.What only the Windows CI leg can confirm, stated plainly:
#[cfg(not(unix))]arm compiles and links onx86_64-pc-windows-msvc. Cross-checking from macOS is not possible here:cargo check --target x86_64-pc-windows-msvcfails inblake3's build script for want ofml64.exe. The authoritative check is theCargo Check & Test (Windows)job inci.yml, which runscargo test --workspaceonwindows-latestand therefore executes both new tests against the real production path.What no CI leg can confirm, and I am not claiming: that the NTFS LSN-ordered-log-prefix premise actually delivers the ordering. Proving it needs power-loss or crash-injection testing on real Windows hardware, which is out of reach of this repo's suites. The tests here assert the barrier's shape and its fail-closed behaviour, not the durability guarantee itself. This is a strict improvement over the previous no-op regardless — the old code obtained no ordering under any model.
Desktop E2E is not required for this change; no FUSE or shell surface is touched.
Windows cost, accepted deliberately. Every
atomic_writeand everyremove_file_durableon Windows now pays an extra file create + write +FlushFileBuffers+ unlink. Scoping the barrier to unlinks only was considered and rejected: withMOVEFILE_WRITE_THROUGHabsent from std'srename, the write path needs it too. Batching the barrier across the delete loops insnapshot_cacheandfloor_store, and skipping it inensure_dirwhen the sweep removed nothing, are real wins but sit outside this diff.Review gates
/security-review— no HIGH or MEDIUM findings. The diff adds no untrusted input, no crypto, no network surface and no secret handling; the barrier file carries a single NUL byte and never sees key or sealed material. Failure propagates fail-closed, so a failed barrier fails the removal instead of letting the caller proceed to the sidecar./simplify— findings folded in: the duplicated temp-write block extracted towrite_synced_temp; the barrier doc cut from 13 lines to 6; the platform mechanism no longer restated onFileStagingStore; a tautological assertion in the test replaced with a real precondition on the kill-point table; positional bools replaced with a namedSurvivorsstruct; absent-versus-corrupted sidecar states no longer conflated. One finding was rejected on evidence — scoping the barrier away fromatomic_writerests on theMOVEFILE_WRITE_THROUGHclaim, which the std sources disprove. One accepted change was then reverted: barriering the already-absentremove_file_durablepath closes a retry-after-barrier-failure window, but costs a directory fsync per already-removed leaf on the per-block GC path, which is not a trade worth making here./crypto-privacy-review— not applicable; the diff touches no key material and no sealed bytes.Closes #665
Summary by CodeRabbit
Bug Fixes
Tests