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