1use std::path::{Path, PathBuf};
19use std::time::{Duration, SystemTime};
20
21use crate::error::{AppError, Result};
22
23pub const STALE: Duration = Duration::from_secs(5);
26const HEARTBEAT: Duration = Duration::from_millis(2_500);
29const POLL: Duration = Duration::from_millis(250);
30pub const ACQUIRE_TIMEOUT: Duration = Duration::from_secs(20);
33
34pub 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#[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 if owns(&self.dir, &self.owner) {
61 let _ = std::fs::remove_dir_all(&self.dir);
65 }
66 }
67}
68
69pub async fn acquire(target: &Path) -> Result<OauthLock> {
72 acquire_with(target, ACQUIRE_TIMEOUT, STALE, POLL).await
73}
74
75pub 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 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 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 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 break;
140 }
141 }
142 })
143 });
144 OauthLock {
145 dir,
146 owner,
147 heartbeat,
148 }
149}
150
151const OWNER_FILE: &str = ".ai-usagebar-owner";
153
154fn owns(dir: &Path, owner: &str) -> bool {
156 std::fs::read(dir.join(OWNER_FILE)).is_ok_and(|bytes| bytes == owner.as_bytes())
157}
158
159fn 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 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 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 assert!(!touch(&dir, "someone-else"));
286 }
287
288 #[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 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}