Skip to main content

ai_usagebar/kimi/
lock.rs

1//! Cross-process lock around a Kimi Code CLI credential refresh, speaking the
2//! CLI's own lock protocol.
3//!
4//! Unlike every other lock in this codebase (which guards a file ai-usagebar
5//! owns and therefore uses `flock` via `cache::acquire_lock_async`), this one
6//! guards a file **another program** owns and refreshes on its own schedule.
7//! kimi-code locks its credential store with `proper-lockfile`, whose contract
8//! is a *directory*: `lock(target)` succeeds by `mkdir`ing `<target>.lock`, an
9//! existing lock directory whose mtime is older than `stale` may be stolen, and
10//! the holder keeps refreshing that mtime while it works. `flock` here would be
11//! invisible to the CLI and both processes would rotate the same refresh token
12//! at once, so this speaks the protocol the other side actually implements.
13//!
14//! Windows is deliberately unlocked: kimi-code disables its own lock there
15//! (`if (process.platform === "win32") return undefined`), so taking one would
16//! only be a lock against ourselves.
17
18use std::path::{Path, PathBuf};
19use std::time::{Duration, SystemTime};
20
21use crate::error::{AppError, Result};
22
23/// proper-lockfile's default `stale` for kimi-code's OAuth lock (5 s), after
24/// which an abandoned lock directory may be taken over.
25pub const STALE: Duration = Duration::from_secs(5);
26/// How often the holder touches the lock directory. proper-lockfile refreshes
27/// at `stale / 2`; matching that keeps a slow refresh from looking abandoned.
28const HEARTBEAT: Duration = Duration::from_millis(2_500);
29const POLL: Duration = Duration::from_millis(250);
30/// Long enough to outlast a peer's refresh round-trip, short enough that a
31/// widget tick never hangs on it.
32pub const ACQUIRE_TIMEOUT: Duration = Duration::from_secs(20);
33
34/// The directory `proper-lockfile` actually creates for `target`.
35pub fn lock_dir_for(target: &Path) -> PathBuf {
36    let mut dir = target.as_os_str().to_os_string();
37    dir.push(".lock");
38    PathBuf::from(dir)
39}
40
41/// Held lock. Dropping it releases the lock and stops the heartbeat.
42#[derive(Debug)]
43pub struct OauthLock {
44    dir: PathBuf,
45    owner: String,
46    heartbeat: Option<tokio::task::JoinHandle<()>>,
47}
48
49impl Drop for OauthLock {
50    fn drop(&mut self) {
51        if let Some(handle) = self.heartbeat.take() {
52            handle.abort();
53        }
54        // Only release what is still ours. A holder delayed past `STALE` can
55        // resume after another process stole the lock and rebuilt the
56        // directory; deleting unconditionally would destroy the replacement
57        // (#495). A missing or foreign owner marker means "not ours" — the
58        // cost of leaving a lock behind is one stale-interval steal by the
59        // next acquirer, never data loss.
60        if owns(&self.dir, &self.owner) {
61            // `remove_dir_all`, not `remove_dir`: an aborted heartbeat can leave
62            // its sentinel behind, and a lock directory we fail to remove would
63            // block the CLI for `STALE` on every one of its own refreshes.
64            let _ = std::fs::remove_dir_all(&self.dir);
65        }
66    }
67}
68
69/// Acquire the lock for `target` (kimi-code's `<home>/oauth/kimi-code`),
70/// stealing it only once it has been abandoned for [`STALE`].
71pub async fn acquire(target: &Path) -> Result<OauthLock> {
72    acquire_with(target, ACQUIRE_TIMEOUT, STALE, POLL).await
73}
74
75/// Seam for tests: they pass a tiny `stale`/`poll` so the steal path does not
76/// need a five-second wall-clock wait.
77pub async fn acquire_with(
78    target: &Path,
79    timeout: Duration,
80    stale: Duration,
81    poll: Duration,
82) -> Result<OauthLock> {
83    let dir = lock_dir_for(target);
84    if let Some(parent) = dir.parent() {
85        std::fs::create_dir_all(parent).map_err(|e| AppError::io_at(parent, e))?;
86    }
87
88    let deadline = tokio::time::Instant::now() + timeout;
89    loop {
90        match std::fs::create_dir(&dir) {
91            Ok(()) => return Ok(hold(dir)),
92            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
93                if is_stale(&dir, stale) {
94                    // Best-effort steal: losing the race just means another
95                    // process took it over first, and the next loop waits.
96                    let _ = std::fs::remove_dir_all(&dir);
97                }
98            }
99            Err(e) => return Err(AppError::io_at(&dir, e)),
100        }
101        if tokio::time::Instant::now() >= deadline {
102            return Err(AppError::Transport(format!(
103                "timed out waiting for the Kimi Code CLI credential lock at {}",
104                crate::display::sanitize_untrusted_path(&dir)
105            )));
106        }
107        tokio::time::sleep(poll).await;
108    }
109}
110
111fn hold(dir: PathBuf) -> OauthLock {
112    // Identity for the ownership check (#495): unique per acquisition, so a
113    // holder that resumes after its lock was stolen can tell the replacement
114    // is not its own. Process id + nanos + a process-local counter keeps it
115    // collision-free without a new dependency.
116    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
117    let owner = format!(
118        "{}-{}-{}",
119        std::process::id(),
120        SystemTime::now()
121            .duration_since(SystemTime::UNIX_EPOCH)
122            .map(|d| d.as_nanos())
123            .unwrap_or(0),
124        COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
125    );
126    // Best-effort marker: if the write fails, this holder simply never
127    // deletes on release (missing marker = not ours), never a foreign one.
128    let _ = std::fs::write(dir.join(OWNER_FILE), owner.as_bytes());
129
130    let beat_dir = dir.clone();
131    let beat_owner = owner.clone();
132    let heartbeat = tokio::runtime::Handle::try_current().ok().map(|_| {
133        tokio::spawn(async move {
134            loop {
135                tokio::time::sleep(HEARTBEAT).await;
136                if !touch(&beat_dir, &beat_owner) {
137                    // The lock was stolen while we slept: stop touching a
138                    // directory that belongs to someone else (#495).
139                    break;
140                }
141            }
142        })
143    });
144    OauthLock {
145        dir,
146        owner,
147        heartbeat,
148    }
149}
150
151/// The acquisition-time identity marker inside a held lock directory.
152const OWNER_FILE: &str = ".ai-usagebar-owner";
153
154/// Whether `dir` is still held under `owner`'s identity.
155fn owns(dir: &Path, owner: &str) -> bool {
156    std::fs::read(dir.join(OWNER_FILE)).is_ok_and(|bytes| bytes == owner.as_bytes())
157}
158
159/// Bump the lock directory's mtime, which is what proper-lockfile reads to
160/// decide whether a lock was abandoned. Adding and removing an entry is how a
161/// POSIX directory's mtime moves — there is no `utimes` in `std`, and pulling a
162/// crate in for one syscall is not worth a dependency.
163///
164/// Returns false when the directory no longer belongs to `owner`, so the
165/// heartbeat stops touching a stolen lock (#495).
166fn touch(dir: &Path, owner: &str) -> bool {
167    if !owns(dir, owner) {
168        return false;
169    }
170    let sentinel = dir.join(".ai-usagebar-heartbeat");
171    if std::fs::write(&sentinel, b"").is_ok() {
172        let _ = std::fs::remove_file(&sentinel);
173    }
174    true
175}
176
177fn is_stale(dir: &Path, stale: Duration) -> bool {
178    let Ok(modified) = std::fs::metadata(dir).and_then(|m| m.modified()) else {
179        // No mtime to trust: leave the lock alone rather than steal blindly.
180        return false;
181    };
182    SystemTime::now()
183        .duration_since(modified)
184        .is_ok_and(|age| age > stale)
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use tempfile::TempDir;
191
192    fn target(td: &TempDir) -> PathBuf {
193        td.path().join("oauth").join("kimi-code")
194    }
195
196    #[test]
197    fn lock_dir_matches_proper_lockfile_naming() {
198        assert_eq!(
199            lock_dir_for(Path::new("/home/u/.kimi-code/oauth/kimi-code")),
200            PathBuf::from("/home/u/.kimi-code/oauth/kimi-code.lock")
201        );
202    }
203
204    #[tokio::test]
205    async fn acquire_creates_the_lock_directory_and_drop_removes_it() {
206        let td = TempDir::new().unwrap();
207        let target = target(&td);
208        let dir = lock_dir_for(&target);
209        {
210            let _lock = acquire_with(&target, Duration::from_secs(1), STALE, POLL)
211                .await
212                .unwrap();
213            assert!(dir.is_dir());
214        }
215        assert!(!dir.exists());
216    }
217
218    #[tokio::test]
219    async fn a_fresh_foreign_lock_is_waited_out_not_stolen() {
220        let td = TempDir::new().unwrap();
221        let target = target(&td);
222        let dir = lock_dir_for(&target);
223        std::fs::create_dir_all(&dir).unwrap();
224
225        let err = acquire_with(
226            &target,
227            Duration::from_millis(60),
228            Duration::from_secs(3_600),
229            Duration::from_millis(10),
230        )
231        .await
232        .unwrap_err();
233        assert!(matches!(err, AppError::Transport(_)), "{err:?}");
234        assert!(dir.is_dir(), "a live peer's lock must survive");
235    }
236
237    #[tokio::test]
238    async fn an_abandoned_lock_is_stolen_once_stale() {
239        let td = TempDir::new().unwrap();
240        let target = target(&td);
241        let dir = lock_dir_for(&target);
242        std::fs::create_dir_all(&dir).unwrap();
243
244        let lock = acquire_with(
245            &target,
246            Duration::from_secs(2),
247            Duration::ZERO,
248            Duration::from_millis(10),
249        )
250        .await
251        .unwrap();
252        assert!(dir.is_dir());
253        drop(lock);
254        assert!(!dir.exists());
255    }
256
257    #[tokio::test]
258    async fn a_leftover_heartbeat_sentinel_does_not_block_release() {
259        let td = TempDir::new().unwrap();
260        let target = target(&td);
261        let dir = lock_dir_for(&target);
262        {
263            let _lock = acquire_with(&target, Duration::from_secs(1), STALE, POLL)
264                .await
265                .unwrap();
266            std::fs::write(dir.join(".ai-usagebar-heartbeat"), b"").unwrap();
267        }
268        assert!(!dir.exists());
269    }
270
271    #[test]
272    fn touch_moves_the_directory_mtime_forward() {
273        let td = TempDir::new().unwrap();
274        let dir = td.path().join("kimi-code.lock");
275        std::fs::create_dir(&dir).unwrap();
276        std::fs::write(dir.join(OWNER_FILE), b"owner").unwrap();
277        let before = std::fs::metadata(&dir).unwrap().modified().unwrap();
278        // Filesystem mtime granularity can be coarse; assert the touch is not
279        // *older*, and that the sentinel never outlives the call.
280        assert!(touch(&dir, "owner"));
281        let after = std::fs::metadata(&dir).unwrap().modified().unwrap();
282        assert!(after >= before);
283        assert!(!dir.join(".ai-usagebar-heartbeat").exists());
284        // A foreign owner's directory is never touched (#495).
285        assert!(!touch(&dir, "someone-else"));
286    }
287
288    /// Regression for #495: a holder delayed past `STALE` resumes after
289    /// another process stole the lock; dropping it must preserve the
290    /// replacement, and its heartbeat must stop touching the foreign
291    /// directory. Reproduction contributed with the issue.
292    #[cfg(unix)]
293    #[tokio::test]
294    async fn dropping_previous_holder_preserves_replacement_lock() {
295        let td = TempDir::new().unwrap();
296        let target = target(&td);
297        let dir = lock_dir_for(&target);
298        let poll = Duration::from_millis(1);
299        let first = acquire_with(&target, Duration::from_secs(1), STALE, poll)
300            .await
301            .unwrap();
302
303        std::fs::File::open(&dir)
304            .unwrap()
305            .set_times(
306                std::fs::FileTimes::new()
307                    .set_modified(SystemTime::now() - STALE - Duration::from_secs(1)),
308            )
309            .unwrap();
310
311        let replacement = acquire_with(&target, Duration::from_secs(1), STALE, poll)
312            .await
313            .unwrap();
314        assert!(dir.is_dir());
315
316        // Give the original holder a full heartbeat interval to notice the
317        // replacement before releasing it. This does not restore ownership.
318        tokio::time::sleep(Duration::from_millis(2_600)).await;
319        drop(first);
320
321        assert!(dir.is_dir(), "old holder removed replacement holder's lock");
322        drop(replacement);
323    }
324}