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}
50
51pub fn read_cached_probe_result() -> Option<OpenCodeProbeResult> {
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<OpenCodeProbeResult> {
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("opencode-probe.json"))
80}
81
82fn lock_path() -> Result<PathBuf, MarsError> {
83 Ok(cache_dir()?.join(".opencode-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) -> CachedProbeOutcome {
201 if !super::should_probe_opencode(installed, mars_offline) {
202 return CachedProbeOutcome::Unavailable;
203 }
204
205 probe_cached_impl(
206 mars_offline,
207 probe_refresh,
208 &cache_path().ok(),
209 super::opencode::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) -> CachedProbeOutcome
221where
222 F: Fn() -> OpenCodeProbeResult,
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) => CachedProbeOutcome::Hit(result),
235 ProbeCacheBranch::Stale(result) => CachedProbeOutcome::Stale(result),
236 ProbeCacheBranch::Unavailable => CachedProbeOutcome::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) -> CachedProbeOutcome
258where
259 F: Fn() -> OpenCodeProbeResult,
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 CachedProbeOutcome::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 CachedProbeOutcome::Miss(probe_result);
275 } else {
276 write_failed_attempt(path, &entry, &probe_result);
277 return CachedProbeOutcome::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 CachedProbeOutcome::Miss(probe_result)
289 } else {
290 CachedProbeOutcome::Failed(probe_result)
291 }
292}
293
294fn write_probe_attempt(path: &Path, probe_result: OpenCodeProbeResult) {
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(
314 path: &Path,
315 existing: &ProbeCacheEntry,
316 failed_probe: &OpenCodeProbeResult,
317) {
318 let now = now_unix_secs();
319 let entry = ProbeCacheEntry {
320 schema_version: SCHEMA_VERSION,
321 fetched_at: existing.fetched_at,
322 last_attempt_at: now,
323 last_error: failed_probe.error.clone(),
324 result: existing.result.clone(),
325 };
326
327 if let Err(e) = write_cache_at(path, &entry) {
328 eprintln!("debug: probe cache write failed: {e}");
329 }
330}
331
332fn spawn_detached_refresh() -> std::io::Result<()> {
333 let mars_bin = std::env::current_exe()?;
334 let mut cmd = std::process::Command::new(mars_bin);
335 cmd.args(["models", "__refresh-probe", "--target", "opencode"]);
336 cmd.stdin(Stdio::null());
337 cmd.stdout(Stdio::null());
338 cmd.stderr(Stdio::null());
339
340 #[cfg(unix)]
341 {
342 use std::os::unix::process::CommandExt;
343 unsafe {
344 cmd.pre_exec(|| {
345 libc::setsid();
346 Ok(())
347 });
348 }
349 }
350
351 #[cfg(windows)]
352 {
353 use std::os::windows::process::CommandExt;
354 cmd.creation_flags(0x00000008);
355 }
356
357 cmd.spawn()?;
358 Ok(())
359}
360
361pub fn run_refresh_probe_command() -> Result<i32, MarsError> {
362 let Some(_lock) = blocking_lock() else {
363 return Ok(0);
364 };
365
366 if let Some(entry) = read_cache_tolerant()
367 && is_fresh(&entry)
368 && is_usable(&entry)
369 {
370 return Ok(0);
371 }
372
373 let probe_result = super::opencode::probe();
374 let now = now_unix_secs();
375 let existing = read_cache_tolerant();
376
377 let entry = if probe_result.model_probe_success {
378 ProbeCacheEntry {
379 schema_version: SCHEMA_VERSION,
380 fetched_at: now,
381 last_attempt_at: now,
382 last_error: None,
383 result: Some(probe_result),
384 }
385 } else {
386 ProbeCacheEntry {
387 schema_version: SCHEMA_VERSION,
388 fetched_at: existing.as_ref().map(|e| e.fetched_at).unwrap_or(0),
389 last_attempt_at: now,
390 last_error: probe_result.error.clone(),
391 result: existing.and_then(|e| e.result),
392 }
393 };
394 let _ = write_cache(&entry);
395
396 Ok(0)
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use std::cell::Cell;
403 use tempfile::TempDir;
404
405 fn ok_result() -> OpenCodeProbeResult {
406 OpenCodeProbeResult {
407 model_slugs: vec!["openai/gpt-5.4".to_string()],
408 model_probe_success: true,
409 error: None,
410 }
411 }
412
413 fn fail_result() -> OpenCodeProbeResult {
414 OpenCodeProbeResult {
415 model_probe_success: false,
416 error: Some("boom".to_string()),
417 ..OpenCodeProbeResult::default()
418 }
419 }
420
421 fn entry(fetched_at: u64, result: Option<OpenCodeProbeResult>) -> ProbeCacheEntry {
422 ProbeCacheEntry {
423 schema_version: SCHEMA_VERSION,
424 fetched_at,
425 last_attempt_at: fetched_at,
426 last_error: None,
427 result,
428 }
429 }
430
431 fn cache_file(temp: &TempDir) -> PathBuf {
432 temp.path().join("availability").join("opencode-probe.json")
433 }
434
435 fn write_entry(path: &Path, entry: &ProbeCacheEntry) {
436 write_cache_at(path, entry).unwrap();
437 }
438
439 #[test]
440 fn fresh_hit_returns_cached_result() {
441 let temp = TempDir::new().unwrap();
442 let path = cache_file(&temp);
443 write_entry(&path, &entry(now_unix_secs(), Some(ok_result())));
444
445 let outcome = probe_cached_impl(
446 false,
447 crate::models::probes::ProbeRefreshMode::Background,
448 &Some(path),
449 fail_result,
450 || Ok(()),
451 );
452 assert!(matches!(outcome, CachedProbeOutcome::Hit(_)));
453 assert_eq!(outcome.result().unwrap().model_slugs[0], "openai/gpt-5.4");
454 }
455
456 #[test]
457 fn stale_entry_with_synchronous_refresh_runs_probe_without_spawn() {
458 let temp = TempDir::new().unwrap();
459 let path = cache_file(&temp);
460 write_entry(&path, &entry(1, Some(ok_result())));
461
462 let spawn_called = Cell::new(false);
463 let probe_called = Cell::new(false);
464 let outcome = probe_cached_impl(
465 false,
466 crate::models::probes::ProbeRefreshMode::Synchronous,
467 &Some(path.clone()),
468 || {
469 probe_called.set(true);
470 OpenCodeProbeResult {
471 model_slugs: vec!["openai/gpt-5.5".to_string()],
472 model_probe_success: true,
473 error: None,
474 }
475 },
476 || {
477 spawn_called.set(true);
478 Ok(())
479 },
480 );
481
482 assert!(probe_called.get());
483 assert!(!spawn_called.get());
484 assert!(matches!(outcome, CachedProbeOutcome::Miss(_)));
485 }
486
487 #[test]
488 fn stale_entry_returns_stale_outcome() {
489 let temp = TempDir::new().unwrap();
490 let path = cache_file(&temp);
491 write_entry(&path, &entry(1, Some(ok_result())));
492
493 let outcome = probe_cached_impl(
494 false,
495 crate::models::probes::ProbeRefreshMode::Background,
496 &Some(path),
497 fail_result,
498 || Ok(()),
499 );
500 assert!(matches!(outcome, CachedProbeOutcome::Stale(_)));
501 }
502
503 #[test]
504 fn stale_cache_preserved_on_failed_probe() {
505 let temp = TempDir::new().unwrap();
506 let path = cache_file(&temp);
507 write_entry(&path, &entry(1, Some(ok_result())));
508
509 let outcome = probe_cached_impl(
510 false,
511 crate::models::probes::ProbeRefreshMode::Background,
512 &Some(path.clone()),
513 fail_result,
514 || Ok(()),
515 );
516
517 assert!(matches!(outcome, CachedProbeOutcome::Stale(_)));
518
519 let on_disk = read_cache_tolerant_at(&path).unwrap();
520 assert!(on_disk.result.as_ref().unwrap().model_probe_success);
521 assert_eq!(on_disk.fetched_at, 1);
522 }
523
524 #[test]
525 fn missing_cache_runs_synchronous_probe() {
526 let temp = TempDir::new().unwrap();
527 let path = cache_file(&temp);
528 let called = Cell::new(false);
529 let outcome = probe_cached_impl(
530 false,
531 crate::models::probes::ProbeRefreshMode::Background,
532 &Some(path.clone()),
533 || {
534 called.set(true);
535 ok_result()
536 },
537 || Ok(()),
538 );
539
540 assert!(called.get());
541 assert!(matches!(outcome, CachedProbeOutcome::Miss(_)));
542 assert!(read_cache_tolerant_at(&path).is_some());
543 }
544
545 #[test]
546 fn invalid_json_is_cache_miss() {
547 let temp = TempDir::new().unwrap();
548 let path = cache_file(&temp);
549 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
550 std::fs::write(&path, "not json").unwrap();
551
552 let outcome = probe_cached_impl(
553 false,
554 crate::models::probes::ProbeRefreshMode::Background,
555 &Some(path),
556 ok_result,
557 || Ok(()),
558 );
559 assert!(matches!(outcome, CachedProbeOutcome::Miss(_)));
560 }
561
562 #[test]
563 fn incompatible_schema_is_cache_miss() {
564 let temp = TempDir::new().unwrap();
565 let path = cache_file(&temp);
566 let mut old = entry(now_unix_secs(), Some(ok_result()));
567 old.schema_version = 999;
568 write_entry(&path, &old);
569
570 let outcome = probe_cached_impl(
571 false,
572 crate::models::probes::ProbeRefreshMode::Background,
573 &Some(path),
574 ok_result,
575 || Ok(()),
576 );
577 assert!(matches!(outcome, CachedProbeOutcome::Miss(_)));
578 }
579
580 #[test]
581 fn future_fetched_at_is_stale() {
582 let future = entry(now_unix_secs() + 3600, Some(ok_result()));
583 assert!(!is_fresh(&future));
584 }
585
586 #[test]
587 fn ttl_override_controls_freshness() {
588 let _guard = EnvGuard::set("MARS_PROBE_CACHE_TTL_SECS", "9999");
589 let recent = entry(now_unix_secs().saturating_sub(10), Some(ok_result()));
590 assert!(is_fresh(&recent));
591 }
592
593 #[test]
594 fn write_failure_degrades_gracefully() {
595 let temp = TempDir::new().unwrap();
596 let path = temp.path().join("availability");
597 std::fs::write(&path, "file blocks directory").unwrap();
598 let blocked = path.join("opencode-probe.json");
599
600 let outcome = probe_cached_impl(
601 false,
602 crate::models::probes::ProbeRefreshMode::Background,
603 &Some(blocked),
604 ok_result,
605 || Ok(()),
606 );
607 assert!(matches!(outcome, CachedProbeOutcome::Miss(_)));
608 }
609
610 #[test]
611 fn cold_probe_failure_returns_failed_outcome_with_error_status() {
612 let temp = TempDir::new().unwrap();
613 let path = cache_file(&temp);
614
615 let outcome = probe_cached_impl(
616 false,
617 crate::models::probes::ProbeRefreshMode::Background,
618 &Some(path),
619 fail_result,
620 || Ok(()),
621 );
622
623 assert!(matches!(outcome, CachedProbeOutcome::Failed(_)));
624 assert_eq!(outcome.cache_status(), "failed");
625 assert!(!outcome.result().unwrap().model_probe_success);
626 assert_eq!(outcome.result().unwrap().error.as_deref(), Some("boom"));
627 }
628
629 struct EnvGuard {
630 key: &'static str,
631 prev: Option<std::ffi::OsString>,
632 }
633
634 impl EnvGuard {
635 fn set(key: &'static str, value: &str) -> Self {
636 let prev = std::env::var_os(key);
637 unsafe { std::env::set_var(key, value) };
638 Self { key, prev }
639 }
640 }
641
642 impl Drop for EnvGuard {
643 fn drop(&mut self) {
644 if let Some(prev) = &self.prev {
645 unsafe { std::env::set_var(self.key, prev) };
646 } else {
647 unsafe { std::env::remove_var(self.key) };
648 }
649 }
650 }
651}