use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use super::{CatalogReportCache, STUCK_REFRESH_BOUND};
use crate::core::update_check::StalenessReport;
use crate::daemon::state::DaemonState;
use tokio::time::Instant as TokioInstant;
const GENEROUS_WAIT: Duration = Duration::from_secs(10);
const BOUND: Duration = Duration::from_millis(250);
const STALLED_TEST_BOUND: Duration = Duration::from_secs(30);
fn stale_report() -> StalenessReport {
StalenessReport {
stale: true,
..StalenessReport::default()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_requests_start_a_single_refresh() {
let cache = Arc::new(CatalogReportCache::new(Duration::from_secs(30)));
let calls = Arc::new(AtomicUsize::new(0));
let mut requests = Vec::new();
for _ in 0..8 {
let cache = Arc::clone(&cache);
let calls = Arc::clone(&calls);
requests.push(tokio::spawn(async move {
cache
.get(
move || {
calls.fetch_add(1, Ordering::SeqCst);
std::thread::sleep(Duration::from_millis(200));
stale_report()
},
GENEROUS_WAIT,
)
.await
}));
}
for request in requests {
let report = request.await.expect("request task");
assert!(report.stale, "every waiter sees the one walk: {report:?}");
}
let calls_for_late = Arc::clone(&calls);
let late = cache
.get(
move || {
calls_for_late.fetch_add(1, Ordering::SeqCst);
StalenessReport::default()
},
GENEROUS_WAIT,
)
.await;
assert!(late.stale, "a request inside the max age reuses the report");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"one walk for nine requests"
);
}
#[tokio::test]
async fn a_report_older_than_max_age_is_recomputed() {
let cache = Arc::new(CatalogReportCache::new(Duration::ZERO));
let first = cache.get(stale_report, GENEROUS_WAIT).await;
assert!(first.stale);
let second = cache.get(StalenessReport::default, GENEROUS_WAIT).await;
assert!(
!second.stale && !second.unknown,
"an expired report must be replaced by the new walk: {second:?}"
);
}
#[tokio::test]
async fn a_panicking_refresh_reports_unknown_not_fresh() {
let cache = Arc::new(CatalogReportCache::new(Duration::from_secs(30)));
let report = cache
.get(
|| -> StalenessReport { panic!("stand-in for a walk that crashed") },
GENEROUS_WAIT,
)
.await;
assert!(report.unknown, "a crashed walk is undetermined: {report:?}");
assert!(!report.stale);
}
#[tokio::test(start_paused = true)]
async fn a_refresh_stuck_past_the_bound_is_reported_unknown() {
let cache = Arc::new(CatalogReportCache::new(Duration::from_secs(30)));
let warm = cache.get(StalenessReport::default, GENEROUS_WAIT).await;
assert!(
!warm.stale && !warm.unknown,
"warm-up must succeed: {warm:?}"
);
tokio::time::advance(Duration::from_secs(31)).await;
{
let mut inner = cache.inner.lock();
inner.refreshing = true;
inner.refresh_started_at = Some(TokioInstant::now());
}
let unreachable = || -> StalenessReport {
unreachable!("refreshing=true must never let `get` start a second walk")
};
let still_warm = cache.get(unreachable, Duration::from_millis(100)).await;
assert!(
!still_warm.unknown && !still_warm.stale,
"a refresh outstanding under the bound still serves the pre-hang \
report: {still_warm:?}"
);
tokio::time::advance(STUCK_REFRESH_BOUND + Duration::from_secs(1)).await;
let after_bound = cache.get(unreachable, Duration::from_millis(100)).await;
assert!(
after_bound.unknown && !after_bound.stale,
"a refresh stuck past the bound must read unknown, never the \
pre-hang report: {after_bound:?}"
);
}
#[cfg(unix)]
#[test]
fn health_answers_while_the_catalog_read_is_stalled() {
const STALL: Duration = Duration::from_millis(1500);
const PHASES: [&str; 4] = [
"the first /health call",
"the second /health call",
"joining the FIFO writer",
"runtime shutdown (a catalog walk still parked on the FIFO)",
];
let tmp = tempfile::TempDir::new().expect("tempdir");
let fifo = crate::core::harness_root::framework_dir(tmp.path())
.join(crate::core::manifest::MANIFEST_FILE);
std::fs::create_dir_all(fifo.parent().expect("manifest path has a parent")).expect("mkdir");
let made = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.expect("run mkfifo");
assert!(made.success(), "mkfifo failed: {made}");
let phase = Arc::new(AtomicUsize::new(0));
let (done, finished) = std::sync::mpsc::channel();
let (root, writer_path, worker_phase) = (tmp.path().to_path_buf(), fifo.clone(), phase.clone());
std::thread::spawn(move || {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread runtime");
let writer = std::thread::spawn(move || {
std::thread::sleep(STALL);
drop(std::fs::OpenOptions::new().write(true).open(&writer_path));
});
runtime.block_on(async {
let state = Arc::new(DaemonState::with_root(root));
for (index, call) in ["first", "second"].into_iter().enumerate() {
worker_phase.store(index, Ordering::SeqCst);
let asked = Instant::now();
let body = crate::daemon::rpc::core_ops::health(&state).await;
let latency = asked.elapsed();
assert!(
latency < BOUND,
"{call} /health took {latency:?} while the catalog read was stalled; \
bound {BOUND:?}"
);
assert_eq!(body.status, "ok");
assert!(
body.catalog_unknown && !body.catalog_stale,
"{call}: an unanswered walk must read unknown, never fresh or stale: \
{body:?}"
);
}
});
worker_phase.store(2, Ordering::SeqCst);
writer.join().expect("writer thread");
worker_phase.store(3, Ordering::SeqCst);
drop(runtime);
}));
let _ = done.send(outcome);
});
match finished.recv_timeout(STALLED_TEST_BOUND) {
Ok(Ok(())) => {}
Ok(Err(panic)) => std::panic::resume_unwind(panic),
Err(wait) => {
drop(
std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&fifo),
);
panic!(
"#8463: the stalled-catalog /health test did not finish within \
{STALLED_TEST_BOUND:?} ({wait}); it was stuck in {}",
PHASES[phase.load(Ordering::SeqCst)]
);
}
}
}