Skip to main content

theway_daemon/executor/
file_lock.rs

1//! `FileLock` — cross-process advisory file lock for the editing tools.
2//!
3//! Why (issue #17): the `edit` tool's read→modify→write cycle is not atomic
4//! across processes. Parallel agents (subagents) sharing one working tree
5//! observed torn files and silently lost edits when two editors touched the
6//! same file concurrently. A plain in-process mutex cannot help — subagents
7//! are separate processes — so this is a kernel-level `flock` (via `fs4`,
8//! already in the dependency tree through tantivy).
9//!
10//! The lock is taken on a **stable lock file keyed by the canonical target
11//! path** (`$TMPDIR/theway-file-locks/<sha256>.lock`), NOT on the target file
12//! itself. Locking the target inode is broken by design here: editors commit
13//! via temp-file + `rename`, which swaps inodes on every write, so an editor
14//! that opens the path late locks the *new* inode and bypasses editors still
15//! queued on the old one (observed as a lost edit in the concurrent-edit
16//! regression test). The hashed lock file never moves, so every editor of the
17//! same path contends on the same inode regardless of rename churn.
18//!
19//! Lock files live under the system temp dir (not next to the target), so
20//! agent runs leave no litter in the edited tree, and they persist between
21//! acquisitions — removing a lock file while waiters hold it would let a
22//! fresh lock file be created and bypass them (the classic unlink race).
23//!
24//! The lock is released when the guard drops (the kernel releases `flock` on
25//! close, so a crashed editor never leaves a stale lock behind).
26
27use std::path::{Path, PathBuf};
28use std::time::Duration;
29
30use fs4::fs_std::FileExt;
31use sha2::{Digest, Sha256};
32
33/// Default wait for a contended lock before failing the tool call.
34const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
35
36/// Poll interval while another process holds the lock. `try_lock_exclusive`
37/// is non-blocking, so the retry loop never blocks the async runtime.
38const RETRY_INTERVAL: Duration = Duration::from_millis(10);
39
40/// Exclusive advisory lock for one target path. RAII: drops → unlock.
41///
42/// `FileLock` is `Send` (so it can live in async tool futures) but not `Sync`.
43pub struct FileLock {
44    _file: std::fs::File,
45}
46
47impl FileLock {
48    /// Lock `path`, waiting up to [`DEFAULT_TIMEOUT`] before failing with
49    /// `WouldBlock`. Locking never creates or modifies the target file.
50    pub async fn acquire(path: &Path) -> std::io::Result<Self> {
51        Self::acquire_with_timeout(path, DEFAULT_TIMEOUT).await
52    }
53
54    /// Lock `path`, waiting up to `timeout`.
55    pub async fn acquire_with_timeout(path: &Path, timeout: Duration) -> std::io::Result<Self> {
56        let lock_path = lock_file_path(path)?;
57        if let Some(parent) = lock_path.parent() {
58            std::fs::create_dir_all(parent)?;
59        }
60        let file = std::fs::OpenOptions::new()
61            .read(true)
62            .write(true)
63            .create(true)
64            .truncate(false)
65            .open(&lock_path)?;
66        let deadline = tokio::time::Instant::now() + timeout;
67        loop {
68            match file.try_lock_exclusive() {
69                Ok(true) => return Ok(Self { _file: file }),
70                // Ok(false) = held elsewhere (would block).
71                Ok(false) => {
72                    if tokio::time::Instant::now() >= deadline {
73                        return Err(std::io::Error::new(
74                            std::io::ErrorKind::WouldBlock,
75                            format!(
76                                "timed out after {timeout:?} waiting for the file lock on {} \
77                                 (another agent editing the same file?)",
78                                path.display()
79                            ),
80                        ));
81                    }
82                    tokio::time::sleep(RETRY_INTERVAL).await;
83                }
84                Err(err) => return Err(err),
85            }
86        }
87    }
88}
89
90/// The stable lock file for `path`: a sha256 of its canonical identity.
91///
92/// Canonicalization resolves symlinks and `..` so different spellings of the
93/// same file contend on the same lock. Missing targets canonicalize via their
94/// parent directory (the edit tool only locks existing files, but the write
95/// path may create them).
96fn lock_file_path(path: &Path) -> std::io::Result<PathBuf> {
97    let canonical = match std::fs::canonicalize(path) {
98        Ok(real) => real,
99        Err(_) => {
100            let parent = match path.parent() {
101                Some(p) if !p.as_os_str().is_empty() => p,
102                _ => Path::new("."),
103            };
104            let parent = std::fs::canonicalize(parent)?;
105            let name = path.file_name().ok_or_else(|| {
106                std::io::Error::new(
107                    std::io::ErrorKind::InvalidInput,
108                    format!("path has no file name: {}", path.display()),
109                )
110            })?;
111            parent.join(name)
112        }
113    };
114    let digest = hex::encode(Sha256::digest(canonical.to_string_lossy().as_bytes()));
115    Ok(std::env::temp_dir()
116        .join("theway-file-locks")
117        .join(format!("{digest}.lock")))
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use tempfile::tempdir;
124
125    #[tokio::test]
126    async fn second_acquire_waits_until_first_release() {
127        let dir = tempdir().unwrap();
128        let p = dir.path().join("shared.txt");
129
130        let first = FileLock::acquire(&p).await.unwrap();
131        // A second open file description in the same process conflicts on
132        // flock (unlike POSIX fcntl locks), so this is a real contention test.
133        assert!(
134            FileLock::acquire_with_timeout(&p, Duration::from_millis(200))
135                .await
136                .is_err()
137        );
138        drop(first);
139        // Released: a fresh acquire succeeds immediately.
140        assert!(
141            FileLock::acquire_with_timeout(&p, Duration::from_secs(5))
142                .await
143                .is_ok()
144        );
145    }
146
147    /// Issue #17: the lock keys on the path, not the target inode. Editors
148    /// commit via temp+rename (inode swap), so an inode-bound lock would let
149    /// a late opener bypass editors queued on the old inode. After a rewrite
150    /// the lock path must be unchanged and still contended.
151    #[tokio::test]
152    async fn lock_identity_survives_target_rewrites() {
153        let dir = tempdir().unwrap();
154        let p = dir.path().join("t.txt");
155        std::fs::write(&p, "v1").unwrap();
156        let lock_before = lock_file_path(&p).unwrap();
157
158        let first = FileLock::acquire(&p).await.unwrap();
159        // Simulate an atomic_write commit: new inode via rename.
160        let tmp = dir.path().join(".tmp");
161        std::fs::write(&tmp, "v2").unwrap();
162        std::fs::rename(&tmp, &p).unwrap();
163
164        // Same lock identity, still contended while `first` is held.
165        assert_eq!(lock_file_path(&p).unwrap(), lock_before);
166        assert!(
167            FileLock::acquire_with_timeout(&p, Duration::from_millis(200))
168                .await
169                .is_err()
170        );
171        drop(first);
172        assert!(
173            FileLock::acquire_with_timeout(&p, Duration::from_secs(5))
174                .await
175                .is_ok()
176        );
177    }
178
179    #[tokio::test]
180    async fn acquire_never_creates_the_target_file() {
181        let dir = tempdir().unwrap();
182        let p = dir.path().join("does-not-exist.txt");
183        let _lock = FileLock::acquire(&p).await.unwrap();
184        assert!(!p.exists(), "locking must not create the target file");
185        assert!(lock_file_path(&p).unwrap().exists());
186    }
187}
188
189#[cfg(test)]
190mod coverage_gap {
191    use super::*;
192
193    #[test]
194    fn lock_file_path_uses_current_directory_for_bare_relative_name() {
195        // Never mutate the process-wide cwd in tests: parallel suites resolve
196        // paths against it. A missing bare file name falls back to the
197        // canonicalized parent (the current directory), which yields the same
198        // digest as the explicit absolute form.
199        let path = lock_file_path(Path::new("bare.txt")).unwrap();
200        let absolute = lock_file_path(&std::env::current_dir().unwrap().join("bare.txt")).unwrap();
201        assert_eq!(path, absolute);
202        assert!(path.parent().is_some());
203    }
204
205    #[test]
206    fn lock_file_path_rejects_path_without_file_name() {
207        let err = lock_file_path(Path::new("")).unwrap_err();
208        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
209        assert!(err.to_string().contains("no file name"));
210    }
211}