1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4
5use serde::{Deserialize, Serialize};
6
7use super::cursor::CursorProbeResult;
8use super::probe_refresh::ProbeCacheBranch;
9use crate::error::MarsError;
10
11const SCHEMA_VERSION: u32 = 1;
12const DEFAULT_TTL_SECS: u64 = 60;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ProbeCacheEntry {
16 pub schema_version: u32,
17 pub fetched_at: u64,
18 pub last_attempt_at: u64,
19 pub last_error: Option<String>,
20 pub result: Option<CursorProbeResult>,
21}
22
23#[derive(Debug, Clone)]
24pub enum CachedCursorProbeOutcome {
25 Hit(CursorProbeResult),
26 Stale(CursorProbeResult),
27 Miss(CursorProbeResult),
28 Unavailable,
29}
30
31impl CachedCursorProbeOutcome {
32 pub fn result(&self) -> Option<&CursorProbeResult> {
33 match self {
34 Self::Hit(r) | Self::Stale(r) | Self::Miss(r) => Some(r),
35 Self::Unavailable => None,
36 }
37 }
38
39 pub fn cache_status(&self) -> &'static str {
40 match self {
41 Self::Hit(_) => "hit",
42 Self::Stale(_) => "stale",
43 Self::Miss(_) => "miss",
44 Self::Unavailable => "skipped",
45 }
46 }
47}
48
49pub fn read_cached_probe_result() -> Option<CursorProbeResult> {
53 let entry = read_cache_tolerant()?;
54 if !is_fresh(&entry) || !is_usable(&entry) {
55 return None;
56 }
57 entry.result
58}
59
60pub fn read_cached_probe_result_usable() -> Option<CursorProbeResult> {
64 let entry = read_cache_tolerant()?;
65 if !is_usable(&entry) {
66 return None;
67 }
68 entry.result
69}
70
71fn cache_dir() -> Result<PathBuf, MarsError> {
72 let root = crate::platform::cache::global_cache_root()?;
73 Ok(root.join("availability"))
74}
75
76fn cache_path() -> Result<PathBuf, MarsError> {
77 Ok(cache_dir()?.join("cursor-probe.json"))
78}
79
80fn lock_path() -> Result<PathBuf, MarsError> {
81 Ok(cache_dir()?.join(".cursor-probe.lock"))
82}
83
84fn ttl_secs() -> u64 {
85 std::env::var("MARS_PROBE_CACHE_TTL_SECS")
86 .ok()
87 .and_then(|v| v.parse::<u64>().ok())
88 .unwrap_or(DEFAULT_TTL_SECS)
89}
90
91fn now_unix_secs() -> u64 {
92 std::time::SystemTime::now()
93 .duration_since(std::time::UNIX_EPOCH)
94 .unwrap_or_default()
95 .as_secs()
96}
97
98fn is_fresh(entry: &ProbeCacheEntry) -> bool {
99 let ttl = ttl_secs();
100 let now = now_unix_secs();
101 if entry.fetched_at > now {
102 return false;
103 }
104 (now - entry.fetched_at) < ttl
105}
106
107fn is_usable(entry: &ProbeCacheEntry) -> bool {
108 entry.result.as_ref().is_some_and(|r| r.model_probe_success)
109}
110
111fn read_cache_tolerant() -> Option<ProbeCacheEntry> {
112 read_cache_tolerant_at(&cache_path().ok()?)
113}
114
115fn read_cache_tolerant_at(path: &Path) -> Option<ProbeCacheEntry> {
116 let content = std::fs::read_to_string(path).ok()?;
117 let entry: ProbeCacheEntry = serde_json::from_str(&content).ok()?;
118 if entry.schema_version != SCHEMA_VERSION {
119 return None;
120 }
121 Some(entry)
122}
123
124fn write_cache(entry: &ProbeCacheEntry) -> Result<(), MarsError> {
125 write_cache_at(&cache_path()?, entry)
126}
127
128fn write_cache_at(path: &Path, entry: &ProbeCacheEntry) -> Result<(), MarsError> {
129 let json = serde_json::to_string_pretty(entry)
130 .map_err(|e| MarsError::Internal(format!("probe cache serialize: {e}")))?;
131 crate::fs::atomic_write(path, json.as_bytes())
132}
133
134struct FileLock {
135 _file: std::fs::File,
136}
137
138fn try_lock() -> Option<FileLock> {
139 lock_at(&lock_path().ok()?, true)
140}
141
142fn blocking_lock() -> Option<FileLock> {
143 lock_at(&lock_path().ok()?, false)
144}
145
146fn lock_at(path: &Path, nonblocking: bool) -> Option<FileLock> {
147 if let Some(parent) = path.parent() {
148 std::fs::create_dir_all(parent).ok()?;
149 }
150 let file = std::fs::OpenOptions::new()
151 .create(true)
152 .write(true)
153 .truncate(false)
154 .open(path)
155 .ok()?;
156
157 #[cfg(unix)]
158 {
159 use std::os::unix::io::AsRawFd;
160 let flags = if nonblocking {
161 libc::LOCK_EX | libc::LOCK_NB
162 } else {
163 libc::LOCK_EX
164 };
165 let ret = unsafe { libc::flock(file.as_raw_fd(), flags) };
166 if ret != 0 {
167 return None;
168 }
169 }
170
171 #[cfg(windows)]
172 {
173 use std::os::windows::io::AsRawHandle;
174 use windows_sys::Win32::Foundation::HANDLE;
175 use windows_sys::Win32::Storage::FileSystem::{
176 LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY, LockFileEx,
177 };
178 let handle = file.as_raw_handle() as HANDLE;
179 let mut overlapped = unsafe { std::mem::zeroed() };
180 let flags = if nonblocking {
181 LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY
182 } else {
183 LOCKFILE_EXCLUSIVE_LOCK
184 };
185 let ret = unsafe { LockFileEx(handle, flags, 0, 1, 0, &mut overlapped) };
186 if ret == 0 {
187 return None;
188 }
189 }
190
191 Some(FileLock { _file: file })
192}
193
194pub fn probe_cached(
195 installed: &HashSet<String>,
196 mars_offline: bool,
197 probe_refresh: super::ProbeRefreshMode,
198) -> CachedCursorProbeOutcome {
199 if !super::should_probe_cursor(installed, mars_offline) {
200 return CachedCursorProbeOutcome::Unavailable;
201 }
202
203 probe_cached_impl(
204 mars_offline,
205 probe_refresh,
206 &cache_path().ok(),
207 super::cursor::probe,
208 || spawn_detached_refresh().map_err(|_| ()),
209 )
210}
211
212fn probe_cached_impl<F, S>(
213 mars_offline: bool,
214 probe_refresh: super::ProbeRefreshMode,
215 path: &Option<PathBuf>,
216 probe: F,
217 spawn_refresh: S,
218) -> CachedCursorProbeOutcome
219where
220 F: Fn() -> CursorProbeResult,
221 S: Fn() -> Result<(), ()>,
222{
223 let cached = path.as_deref().and_then(read_cache_tolerant_at);
224 match super::probe_refresh::resolve_probe_cache_branch(
225 cached,
226 mars_offline,
227 probe_refresh,
228 |entry| entry.result.as_ref().filter(|_| is_usable(entry)),
229 is_fresh,
230 || trigger_background_refresh_with(spawn_refresh),
231 ) {
232 ProbeCacheBranch::Hit(result) => CachedCursorProbeOutcome::Hit(result),
233 ProbeCacheBranch::Stale(result) => CachedCursorProbeOutcome::Stale(result),
234 ProbeCacheBranch::Unavailable => CachedCursorProbeOutcome::Unavailable,
235 ProbeCacheBranch::SynchronousProbe => synchronous_probe_with(path, probe),
236 }
237}
238
239fn trigger_background_refresh_with<S>(spawn_refresh: S)
240where
241 S: Fn() -> Result<(), ()>,
242{
243 let Some(lock) = try_lock() else { return };
244 if let Some(entry) = read_cache_tolerant()
245 && is_fresh(&entry)
246 && is_usable(&entry)
247 {
248 drop(lock);
249 return;
250 }
251 let _ = spawn_refresh();
252 drop(lock);
253}
254
255fn synchronous_probe_with<F>(path: &Option<PathBuf>, probe: F) -> CachedCursorProbeOutcome
256where
257 F: Fn() -> CursorProbeResult,
258{
259 let lock = blocking_lock();
260
261 if lock.is_some()
262 && let Some(path) = path
263 && let Some(entry) = read_cache_tolerant_at(path)
264 && is_usable(&entry)
265 {
266 if is_fresh(&entry) {
267 return CachedCursorProbeOutcome::Hit(entry.result.unwrap());
268 }
269 let probe_result = probe();
270 if probe_result.model_probe_success {
271 write_probe_attempt(path, probe_result.clone());
272 return CachedCursorProbeOutcome::Miss(probe_result);
273 } else {
274 write_failed_attempt(path, &entry, &probe_result);
275 return CachedCursorProbeOutcome::Stale(entry.result.unwrap());
276 }
277 }
278
279 let probe_result = probe();
280 if let Some(path) = path {
281 write_probe_attempt(path, probe_result.clone());
282 }
283 drop(lock);
284
285 if probe_result.model_probe_success {
286 CachedCursorProbeOutcome::Miss(probe_result)
287 } else {
288 CachedCursorProbeOutcome::Unavailable
289 }
290}
291
292fn write_probe_attempt(path: &Path, probe_result: CursorProbeResult) {
293 let now = now_unix_secs();
294 let entry = ProbeCacheEntry {
295 schema_version: SCHEMA_VERSION,
296 fetched_at: now,
297 last_attempt_at: now,
298 last_error: if probe_result.model_probe_success {
299 None
300 } else {
301 probe_result.error.clone()
302 },
303 result: Some(probe_result),
304 };
305
306 if let Err(e) = write_cache_at(path, &entry) {
307 eprintln!("debug: probe cache write failed: {e}");
308 }
309}
310
311fn write_failed_attempt(path: &Path, existing: &ProbeCacheEntry, failed_probe: &CursorProbeResult) {
312 let now = now_unix_secs();
313 let entry = ProbeCacheEntry {
314 schema_version: SCHEMA_VERSION,
315 fetched_at: existing.fetched_at,
316 last_attempt_at: now,
317 last_error: failed_probe.error.clone(),
318 result: existing.result.clone(),
319 };
320
321 if let Err(e) = write_cache_at(path, &entry) {
322 eprintln!("debug: probe cache write failed: {e}");
323 }
324}
325
326fn spawn_detached_refresh() -> std::io::Result<()> {
327 let mars_bin = std::env::current_exe()?;
328 let mut cmd = std::process::Command::new(mars_bin);
329 cmd.args(["models", "__refresh-probe", "--target", "cursor"]);
330 cmd.stdin(Stdio::null());
331 cmd.stdout(Stdio::null());
332 cmd.stderr(Stdio::null());
333
334 #[cfg(unix)]
335 {
336 use std::os::unix::process::CommandExt;
337 unsafe {
338 cmd.pre_exec(|| {
339 libc::setsid();
340 Ok(())
341 });
342 }
343 }
344
345 #[cfg(windows)]
346 {
347 use std::os::windows::process::CommandExt;
348 cmd.creation_flags(0x00000008);
349 }
350
351 cmd.spawn()?;
352 Ok(())
353}
354
355pub fn run_refresh_probe_command() -> Result<i32, MarsError> {
356 let Some(_lock) = blocking_lock() else {
357 return Ok(0);
358 };
359
360 if let Some(entry) = read_cache_tolerant()
361 && is_fresh(&entry)
362 && is_usable(&entry)
363 {
364 return Ok(0);
365 }
366
367 let probe_result = super::cursor::probe();
368 let now = now_unix_secs();
369 let existing = read_cache_tolerant();
370
371 let entry = if probe_result.model_probe_success {
372 ProbeCacheEntry {
373 schema_version: SCHEMA_VERSION,
374 fetched_at: now,
375 last_attempt_at: now,
376 last_error: None,
377 result: Some(probe_result),
378 }
379 } else {
380 ProbeCacheEntry {
381 schema_version: SCHEMA_VERSION,
382 fetched_at: existing.as_ref().map(|e| e.fetched_at).unwrap_or(0),
383 last_attempt_at: now,
384 last_error: probe_result.error.clone(),
385 result: existing.and_then(|e| e.result),
386 }
387 };
388 let _ = write_cache(&entry);
389
390 Ok(0)
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use std::cell::Cell;
397 use tempfile::TempDir;
398
399 fn ok_result() -> CursorProbeResult {
400 CursorProbeResult {
401 slugs: vec!["gpt-5.5-high".to_string()],
402 model_probe_success: true,
403 error: None,
404 }
405 }
406
407 fn fail_result() -> CursorProbeResult {
408 CursorProbeResult {
409 model_probe_success: false,
410 error: Some("boom".to_string()),
411 ..CursorProbeResult::default()
412 }
413 }
414
415 fn entry(fetched_at: u64, result: Option<CursorProbeResult>) -> ProbeCacheEntry {
416 ProbeCacheEntry {
417 schema_version: SCHEMA_VERSION,
418 fetched_at,
419 last_attempt_at: fetched_at,
420 last_error: None,
421 result,
422 }
423 }
424
425 fn cache_file(temp: &TempDir) -> PathBuf {
426 temp.path().join("availability").join("cursor-probe.json")
427 }
428
429 fn write_entry(path: &Path, entry: &ProbeCacheEntry) {
430 write_cache_at(path, entry).unwrap();
431 }
432
433 #[test]
434 fn fresh_hit_returns_cached_result() {
435 let temp = TempDir::new().unwrap();
436 let path = cache_file(&temp);
437 write_entry(&path, &entry(now_unix_secs(), Some(ok_result())));
438
439 let outcome = probe_cached_impl(
440 false,
441 crate::models::probes::ProbeRefreshMode::Background,
442 &Some(path),
443 fail_result,
444 || Ok(()),
445 );
446 assert!(matches!(outcome, CachedCursorProbeOutcome::Hit(_)));
447 assert_eq!(outcome.result().unwrap().slugs[0], "gpt-5.5-high");
448 }
449
450 #[test]
451 fn stale_entry_returns_stale_outcome() {
452 let temp = TempDir::new().unwrap();
453 let path = cache_file(&temp);
454 write_entry(&path, &entry(1, Some(ok_result())));
455
456 let outcome = probe_cached_impl(
457 false,
458 crate::models::probes::ProbeRefreshMode::Background,
459 &Some(path),
460 fail_result,
461 || Ok(()),
462 );
463 assert!(matches!(outcome, CachedCursorProbeOutcome::Stale(_)));
464 }
465
466 #[test]
467 fn stale_cache_preserved_on_failed_probe() {
468 let temp = TempDir::new().unwrap();
469 let path = cache_file(&temp);
470 write_entry(&path, &entry(1, Some(ok_result())));
471
472 let outcome = probe_cached_impl(
473 false,
474 crate::models::probes::ProbeRefreshMode::Background,
475 &Some(path.clone()),
476 fail_result,
477 || Ok(()),
478 );
479
480 assert!(matches!(outcome, CachedCursorProbeOutcome::Stale(_)));
481
482 let on_disk = read_cache_tolerant_at(&path).unwrap();
483 assert!(on_disk.result.as_ref().unwrap().model_probe_success);
484 assert_eq!(on_disk.fetched_at, 1);
485 }
486
487 #[test]
488 fn missing_cache_runs_synchronous_probe() {
489 let temp = TempDir::new().unwrap();
490 let path = cache_file(&temp);
491 let called = Cell::new(false);
492 let outcome = probe_cached_impl(
493 false,
494 crate::models::probes::ProbeRefreshMode::Background,
495 &Some(path.clone()),
496 || {
497 called.set(true);
498 ok_result()
499 },
500 || Ok(()),
501 );
502
503 assert!(called.get());
504 assert!(matches!(outcome, CachedCursorProbeOutcome::Miss(_)));
505 assert!(read_cache_tolerant_at(&path).is_some());
506 }
507
508 #[test]
509 fn invalid_json_is_cache_miss() {
510 let temp = TempDir::new().unwrap();
511 let path = cache_file(&temp);
512 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
513 std::fs::write(&path, "not json").unwrap();
514
515 let outcome = probe_cached_impl(
516 false,
517 crate::models::probes::ProbeRefreshMode::Background,
518 &Some(path),
519 ok_result,
520 || Ok(()),
521 );
522 assert!(matches!(outcome, CachedCursorProbeOutcome::Miss(_)));
523 }
524
525 #[test]
526 fn incompatible_schema_is_cache_miss() {
527 let temp = TempDir::new().unwrap();
528 let path = cache_file(&temp);
529 let mut old = entry(now_unix_secs(), Some(ok_result()));
530 old.schema_version = 999;
531 write_entry(&path, &old);
532
533 let outcome = probe_cached_impl(
534 false,
535 crate::models::probes::ProbeRefreshMode::Background,
536 &Some(path),
537 ok_result,
538 || Ok(()),
539 );
540 assert!(matches!(outcome, CachedCursorProbeOutcome::Miss(_)));
541 }
542
543 #[test]
544 fn future_fetched_at_is_stale() {
545 let future = entry(now_unix_secs() + 3600, Some(ok_result()));
546 assert!(!is_fresh(&future));
547 }
548
549 #[test]
550 fn ttl_override_controls_freshness() {
551 let _guard = EnvGuard::set("MARS_PROBE_CACHE_TTL_SECS", "9999");
552 let recent = entry(now_unix_secs().saturating_sub(10), Some(ok_result()));
553 assert!(is_fresh(&recent));
554 }
555
556 #[test]
557 fn write_failure_degrades_gracefully() {
558 let temp = TempDir::new().unwrap();
559 let path = temp.path().join("availability");
560 std::fs::write(&path, "file blocks directory").unwrap();
561 let blocked = path.join("cursor-probe.json");
562
563 let outcome = probe_cached_impl(
564 false,
565 crate::models::probes::ProbeRefreshMode::Background,
566 &Some(blocked),
567 ok_result,
568 || Ok(()),
569 );
570 assert!(matches!(outcome, CachedCursorProbeOutcome::Miss(_)));
571 }
572
573 struct EnvGuard {
574 key: &'static str,
575 prev: Option<std::ffi::OsString>,
576 }
577
578 impl EnvGuard {
579 fn set(key: &'static str, value: &str) -> Self {
580 let prev = std::env::var_os(key);
581 unsafe { std::env::set_var(key, value) };
582 Self { key, prev }
583 }
584 }
585
586 impl Drop for EnvGuard {
587 fn drop(&mut self) {
588 if let Some(prev) = &self.prev {
589 unsafe { std::env::set_var(self.key, prev) };
590 } else {
591 unsafe { std::env::remove_var(self.key) };
592 }
593 }
594 }
595}