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}