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