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