use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock, Mutex, PoisonError};
use std::time::{Duration, Instant};
use trusty_agents_common::agent_content::{
AgentContentError, DevOverride, ResolvedContent, resolve_content_in,
};
use super::bundle_cache::{CacheError, Fallback, UpdateOutcome, github_source, install_if_missing};
pub const OFFLINE_ENV: &str = "TRUSTY_CONTENT_OFFLINE";
pub const RETRY_AFTER: Duration = Duration::from_secs(60);
pub fn resolve_or_fetch(dev: DevOverride) -> Result<ResolvedContent, AgentContentError> {
let cache = trusty_common::content::default_cache_dir().ok_or(AgentContentError::NoCacheDir)?;
resolve_or_fetch_at(&cache, dev)
}
pub fn resolve_or_fetch_at(
cache: &Path,
dev: DevOverride,
) -> Result<ResolvedContent, AgentContentError> {
if let Some(value) = std::env::var(OFFLINE_ENV)
.ok()
.filter(|v| offline_from(Some(v)))
{
return resolve_offline_in(cache, dev, &value);
}
resolve_or_fetch_in(cache, dev, fetch_from_github)
}
#[cfg(test)]
type FetchFn = fn(&Path) -> Result<Option<UpdateOutcome>, CacheError>;
#[cfg(test)]
thread_local! {
pub(crate) static FETCH_OVERRIDE: std::cell::Cell<Option<FetchFn>> =
const { std::cell::Cell::new(None) };
}
fn resolve_offline_in(
cache: &Path,
dev: DevOverride,
value: &str,
) -> Result<ResolvedContent, AgentContentError> {
match resolve_content_in(cache, dev) {
Err(AgentContentError::NotInstalled { .. }) => Err(AgentContentError::FetchFailed {
reason: format!(
"`{OFFLINE_ENV}={}` is set, which turns the first-use fetch off \
(unset it to let tm fetch the release itself)",
value.trim()
),
}),
other => other,
}
}
fn fetch_from_github(cache: &Path) -> Result<Option<UpdateOutcome>, CacheError> {
#[cfg(test)]
if let Some(fake) = FETCH_OVERRIDE.with(std::cell::Cell::get) {
return fake(cache);
}
let owned = cache.to_path_buf();
let thread_err = |source: std::io::Error| CacheError::Io {
action: "fetch the content release into",
path: cache.to_path_buf(),
source,
};
std::thread::Builder::new()
.name("content-first-use".to_owned())
.spawn(move || {
let source = github_source().map_err(|e| CacheError::Network {
url: e.url,
reason: e.reason,
tag: None,
fallback: Fallback::None,
})?;
install_if_missing(&owned, &source)
})
.map_err(thread_err)?
.join()
.map_err(|_| thread_err(std::io::Error::other("the fetch thread panicked")))?
}
pub fn resolve_or_fetch_in<F>(
cache: &Path,
dev: DevOverride,
fetch: F,
) -> Result<ResolvedContent, AgentContentError>
where
F: FnOnce(&Path) -> Result<Option<UpdateOutcome>, CacheError>,
{
static FAILURES: LazyLock<FailureMemo> = LazyLock::new(|| FailureMemo::new(RETRY_AFTER));
resolve_or_fetch_with(cache, dev, fetch, &FAILURES, Instant::now)
}
pub(crate) fn resolve_or_fetch_with<F>(
cache: &Path,
dev: DevOverride,
fetch: F,
memo: &FailureMemo,
now: impl Fn() -> Instant,
) -> Result<ResolvedContent, AgentContentError>
where
F: FnOnce(&Path) -> Result<Option<UpdateOutcome>, CacheError>,
{
match resolve_content_in(cache, dev.clone()) {
Err(AgentContentError::NotInstalled { .. }) => {}
other => return other,
}
memo.refuse_recent(cache, now())?;
off_worker(|| fetch_serialized(cache, dev, fetch, memo, &now))
}
fn fetch_serialized<F>(
cache: &Path,
dev: DevOverride,
fetch: F,
memo: &FailureMemo,
now: &impl Fn() -> Instant,
) -> Result<ResolvedContent, AgentContentError>
where
F: FnOnce(&Path) -> Result<Option<UpdateOutcome>, CacheError>,
{
let gate = memo.gate(cache);
let _held = gate.lock().unwrap_or_else(PoisonError::into_inner);
match resolve_content_in(cache, dev.clone()) {
Err(AgentContentError::NotInstalled { .. }) => {}
other => return other,
}
memo.refuse_recent(cache, now())?;
match fetch(cache) {
Ok(Some(outcome)) => tracing::info!(
tag = %outcome.tag,
sha256 = %outcome.sha256,
"no instructional content was installed; pinned {} on first use",
outcome.tag
),
Ok(None) => tracing::debug!("another writer pinned the content release first"),
Err(e) => {
let reason = e.to_string();
memo.record(cache, now(), &reason);
return Err(AgentContentError::FetchFailed { reason });
}
}
memo.clear(cache);
resolve_content_in(cache, dev)
}
fn off_worker<T>(work: impl FnOnce() -> T) -> T {
use tokio::runtime::{Handle, RuntimeFlavor};
match Handle::try_current() {
Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
tokio::task::block_in_place(work)
}
_ => work(),
}
}
#[derive(Debug)]
pub(crate) struct FailureMemo {
window: Duration,
failures: Mutex<HashMap<PathBuf, (Instant, String)>>,
gates: Mutex<HashMap<PathBuf, Arc<Mutex<()>>>>,
}
impl FailureMemo {
pub(crate) fn new(window: Duration) -> Self {
Self {
window,
failures: Mutex::new(HashMap::new()),
gates: Mutex::new(HashMap::new()),
}
}
fn gate(&self, cache: &Path) -> Arc<Mutex<()>> {
let mut gates = self.gates.lock().unwrap_or_else(PoisonError::into_inner);
gates.entry(cache.to_path_buf()).or_default().clone()
}
fn refuse_recent(&self, cache: &Path, now: Instant) -> Result<(), AgentContentError> {
let failures = self.failures.lock().unwrap_or_else(PoisonError::into_inner);
match failures
.get(cache)
.filter(|(at, _)| now.saturating_duration_since(*at) < self.window)
{
Some((_, reason)) => Err(AgentContentError::FetchFailed {
reason: format!(
"{reason} (a fetch under {} s ago failed, so tm did not retry yet)",
self.window.as_secs()
),
}),
None => Ok(()),
}
}
fn record(&self, cache: &Path, now: Instant, reason: &str) {
let mut failures = self.failures.lock().unwrap_or_else(|e| e.into_inner());
failures.insert(cache.to_path_buf(), (now, reason.to_owned()));
}
fn clear(&self, cache: &Path) {
let mut failures = self.failures.lock().unwrap_or_else(|e| e.into_inner());
failures.remove(cache);
}
}
fn offline_from(value: Option<&str>) -> bool {
value.is_some_and(|v| !v.trim().is_empty() && v.trim() != "0")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offline_env_is_read_as_a_switch() {
assert!(!offline_from(None));
assert!(!offline_from(Some("")));
assert!(!offline_from(Some("0")));
assert!(offline_from(Some("1")));
assert!(offline_from(Some("yes")));
}
}