From b96735da01ecd2052b9dcd574ec33b7347e48c60 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Fri, 30 Oct 2026 07:49:03 -0600 Subject: [PATCH] fix(store): truncating an append-only log goes through a write handle on Windows (P11c) std's append(true) opens without FILE_WRITE_DATA, so SetEndOfFile on that handle is refused (PermissionDenied) on Windows: the torn-line cut on open (chain::tests::an_unfinished_last_line_is_cut_away_on_open) and the roll-back of a failed append (chain and journal) could not truncate. Not a rename/share-delete problem: std already opens with all three sharing flags. chain::truncate cuts through a short-lived write handle on Windows and the handle itself elsewhere; the journal's roll-back uses it too. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-store/src/chain.rs | 28 ++++++++++++++++++++++++---- crates/ledger-store/src/journal.rs | 3 +-- docs/packaging.md | 8 ++++++++ 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/crates/ledger-store/src/chain.rs b/crates/ledger-store/src/chain.rs index 8c87381..b7ce5fb 100644 --- a/crates/ledger-store/src/chain.rs +++ b/crates/ledger-store/src/chain.rs @@ -211,8 +211,7 @@ impl ChainLog { crate::winsec::set_owner_only(path)?; } if read.torn { - file.set_len(read.good_len)?; - file.sync_all()?; + truncate(&file, path, read.good_len)?; } // A log just made is in its folder for good (its entry synced). #[cfg(unix)] @@ -280,8 +279,7 @@ impl ChainLog { .and_then(|()| self.file.sync_data()); if let Err(e) = written { self.broken = true; - let _ = self.file.set_len(before); - let _ = self.file.sync_all(); + let _ = truncate(&self.file, &self.path, before); return Err(e.into()); } self.head = Head { seq, hash }; @@ -289,6 +287,28 @@ impl ChainLog { } } +/// Cut the log `path` (open as `file`) to `len` bytes and sync it. On +/// Windows an append-only handle (which is what `append(true)` opens) has +/// no right to truncate, so a short-lived handle with write access does it; +/// the log is opened with the sharing std gives every handle (read, write +/// and delete), so both can be open at once. +pub(crate) fn truncate(file: &File, path: &Path, len: u64) -> std::io::Result<()> { + #[cfg(windows)] + { + let cutter = OpenOptions::new().write(true).open(path)?; + cutter.set_len(len)?; + cutter.sync_all()?; + let _ = file; + Ok(()) + } + #[cfg(not(windows))] + { + let _ = path; + file.set_len(len)?; + file.sync_all() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-store/src/journal.rs b/crates/ledger-store/src/journal.rs index a86f7a3..f8d8d9b 100644 --- a/crates/ledger-store/src/journal.rs +++ b/crates/ledger-store/src/journal.rs @@ -228,8 +228,7 @@ impl JournalWriter { .and_then(|()| self.file.write_all(b"\n")) .and_then(|()| self.file.sync_all()); if let Err(e) = written { - let _ = self.file.set_len(before); - let _ = self.file.sync_all(); + let _ = crate::chain::truncate(&self.file, &self.path, before); return Err(e.into()); } self.prev_hash = sha256_hex(bytes.as_bytes()); diff --git a/docs/packaging.md b/docs/packaging.md index 5312029..8e5c03f 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -213,4 +213,12 @@ not a `cfg` gap. Today Unix-only, found by grep (2026-10-29): host's `close_range` stay Linux-only on purpose (D-32: nothing here confines a process, so nothing is promised where they are absent). macOS, which has no `renameat2`, still looks before it moves. +- Windows file semantics the port met (P11c): an `append(true)` handle has + no right to truncate, so the chain log and the journal cut a torn tail + or roll back a failed write through a short-lived write handle + (`chain::truncate`); a held `LockFileEx` lock is mandatory, so locks sit + on dedicated `*.lock` files and tests never read those; git refuses the + `\\?\` path `canonicalize` returns (`root::plain_path`). std opens every + file with read, write and delete sharing, so a rename never meets an + open handle of ours. - The `portable` job runs Windows as a gate (`experimental: false`), since the port landed (P11c).