Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@

### Fixed

- The filesystem WAL writer lease now attempts an explicit unlock when its
owning guard drops. A retained duplicate descriptor no longer extends a
successfully released lease and blocks immediate writer takeover. Live-writer
exclusion and the successor's independent lease remain enforced.

- Native pure executable-package providers now enforce the compiler-owned
projection artifact, node, path, text, and exact-field bounds. The pure output
budget remains Core-declared; the anchored route's fixed output ceiling is
Expand Down
65 changes: 62 additions & 3 deletions crates/warp-core/src/causal_wal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5579,7 +5579,7 @@ pub struct FilesystemWalStore {
active_epoch: Option<WriterEpoch>,
closed_epochs: Vec<WriterEpoch>,
epoch_closures: BTreeMap<WriterEpochId, WriterEpochClosure>,
writer_lock: Option<File>,
writer_lock: Option<WriterEpochLock>,
manifests: Vec<WalManifest>,
sync_evidence: Vec<FilesystemSyncEvidence>,
#[cfg(any(test, feature = "host_test"))]
Expand Down Expand Up @@ -8457,7 +8457,18 @@ fn reconcile_writer_epoch_closures(
Ok(())
}

fn acquire_writer_epoch_lock(root: &Path) -> Result<File, WalStoreError> {
#[derive(Debug)]
struct WriterEpochLock(File);

impl Drop for WriterEpochLock {
fn drop(&mut self) {
// A concurrent fork can retain the open file description until exec.
// Best-effort explicit release must precede closing this descriptor.
let _ = self.0.unlock();
}
}

fn acquire_writer_epoch_lock(root: &Path) -> Result<WriterEpochLock, WalStoreError> {
let lock_path = root.join("writer-epoch.lock");
let lock = OpenOptions::new()
.create(true)
Expand All @@ -8471,7 +8482,55 @@ fn acquire_writer_epoch_lock(root: &Path) -> Result<File, WalStoreError> {
std::fs::TryLockError::Error(error) => Err(error.into()),
};
}
Ok(lock)
Ok(WriterEpochLock(lock))
}

#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic)]
mod writer_lease_tests {
use super::*;

fn scratch_root() -> PathBuf {
const MAX_DIRECTORY_ATTEMPTS: usize = 1_024;
let parent = PathBuf::from("target/warp-core-test-tmp");
fs::create_dir_all(&parent).expect("scratch parent");
for ordinal in 0..MAX_DIRECTORY_ATTEMPTS {
let root = parent.join(format!("retained-writer-lease-{ordinal}"));
match fs::create_dir(&root) {
Ok(()) => return root,
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => panic!("scratch directory: {error}"),
}
}
panic!("no unclaimed scratch directory");
}

#[test]
fn owner_drop_releases_retained_descriptor_without_releasing_successor() {
let root = scratch_root();
let lease = acquire_writer_epoch_lock(&root).expect("first writer");
// Model the open-file-description lifetime retained across fork before
// exec closes inherited descriptors, without relying on race timing.
let retained = lease.0.try_clone().expect("retained descriptor");
assert!(matches!(
acquire_writer_epoch_lock(&root),
Err(WalStoreError::WriterEpochLeaseUnavailable)
));
drop(lease);
let successor = acquire_writer_epoch_lock(&root).expect("successor after owner drops");
assert!(matches!(
acquire_writer_epoch_lock(&root),
Err(WalStoreError::WriterEpochLeaseUnavailable)
));
drop(retained);
assert!(matches!(
acquire_writer_epoch_lock(&root),
Err(WalStoreError::WriterEpochLeaseUnavailable)
));
drop(successor);
drop(acquire_writer_epoch_lock(&root).expect("next successor"));
fs::remove_dir_all(root).expect("remove only this invocation's scratch directory");
}
}

fn sync_directory_store(path: &Path) -> Result<(), WalStoreError> {
Expand Down
11 changes: 11 additions & 0 deletions docs/topics/WAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,17 @@ Duplicate identities, stale or missing predecessor links, reused fencing
evidence, LSN regression, corrupted ledgers, and commits without their epoch
ledger fail closed before append.

The store's internal lease guard explicitly attempts to unlock before its file
descriptor closes, both when the store drops and when `close_epoch` releases
the guard after persisting the closed epoch. This avoids extending that owner's
lease merely because another descriptor retains the same open file description,
as can happen between fork and exec. Closing only one descriptor is insufficient
under Rust's [file-lock lifetime contract](https://doc.rust-lang.org/std/fs/struct.File.html#method.unlock).
Drop is a best-effort fallback: it cannot return an unlock error and does not
prove release succeeded after an OS error. A contender must still acquire its
own lock. Explicit fallible release and the broader process-bound ownership
contract remain in [issue #718](https://github.com/flyingrobots/echo/issues/718).

The operating-system lease is the filesystem adapter's exclusion authority.
The persisted fencing, process, host, and lease fields are deterministic
chain-position markers, not ambient PID or machine measurements and not a
Expand Down
Loading