use std::future::Future;
use std::time::Duration;
use crate::service::persistence::PersistedIndex;
use super::scan::is_likely_external_volume;
use super::warmboot_index_timeout;
pub async fn restore_one_index_bounded<F, Fut>(entry: PersistedIndex, restore_fn: F) -> bool
where
F: FnOnce(PersistedIndex) -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let deadline: Duration = warmboot_index_timeout();
let index_id = entry.id.clone();
let root_path = entry.root_path.clone();
let handle = tokio::runtime::Handle::current();
let task = tokio::task::spawn_blocking(move || handle.block_on(restore_fn(entry)));
match tokio::time::timeout(deadline, task).await {
Ok(Ok(())) => {
true
}
Ok(Err(join_err)) => {
tracing::error!(
"warm-boot: index '{index_id}' restore task panicked — skipping (issue #718). \
Error: {join_err}"
);
false
}
Err(_elapsed) => {
let is_external = is_likely_external_volume(&root_path);
if is_external {
tracing::warn!(
"warm-boot: index '{index_id}' restore TIMED OUT (>{:.0}s) — path {} \
is on an external/removable volume. \
Under launchd this is typically a TCC denial. \
HINT: grant Full Disk Access to the launchd agent in \
System Settings → Privacy & Security → Full Disk Access, \
or move the index off the external volume. \
Skipping this index — other indexes continue restoring. (issue #718)",
deadline.as_secs_f32(),
root_path.display(),
);
} else {
tracing::warn!(
"warm-boot: index '{index_id}' restore TIMED OUT (>{:.0}s) — path {}. \
The path may be on a slow or permission-restricted filesystem. \
Skipping this index — other indexes continue restoring. (issue #718)",
deadline.as_secs_f32(),
root_path.display(),
);
}
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::service::persistence::PersistedIndex;
fn dummy_entry(id: &str, path: &str) -> PersistedIndex {
PersistedIndex {
id: id.to_string(),
root_path: std::path::PathBuf::from(path),
colocated: false,
..Default::default()
}
}
#[tokio::test]
async fn restore_bounded_returns_true_for_immediate_completion() {
let entry = dummy_entry("test-ok", "/tmp/trusty-718-restore-ok");
let result = restore_one_index_bounded(entry, |_e| async {}).await;
assert!(result, "an immediately-completing restore must return true");
}
#[tokio::test]
#[serial_test::serial]
async fn restore_bounded_returns_false_for_slow_restore() {
unsafe { std::env::set_var("TRUSTY_WARMBOOT_INDEX_TIMEOUT_SECS", "1") };
let entry = dummy_entry(
"test-slow",
"/Volumes/SSD1/slow-index", );
let result = restore_one_index_bounded(entry, |_e| async {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
})
.await;
unsafe { std::env::remove_var("TRUSTY_WARMBOOT_INDEX_TIMEOUT_SECS") };
assert!(
!result,
"a restore that exceeds the deadline must return false"
);
}
#[tokio::test]
#[serial_test::serial]
async fn restore_bounded_multiple_timeouts_do_not_accumulate_indefinitely() {
unsafe { std::env::set_var("TRUSTY_WARMBOOT_INDEX_TIMEOUT_SECS", "1") };
let start = std::time::Instant::now();
for i in 0..3 {
let entry = dummy_entry(&format!("test-multi-{i}"), "/Volumes/SSD1/idx");
let result = restore_one_index_bounded(entry, |_e| async {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
})
.await;
assert!(!result, "entry {i} must time out and return false");
}
unsafe { std::env::remove_var("TRUSTY_WARMBOOT_INDEX_TIMEOUT_SECS") };
assert!(
start.elapsed() < std::time::Duration::from_secs(10),
"3 timed-out restores must complete within 10 s total, elapsed: {:?}",
start.elapsed()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial_test::serial]
async fn restore_bounded_runtime_stays_responsive_during_slow_blocking_restore() {
unsafe { std::env::set_var("TRUSTY_WARMBOOT_INDEX_TIMEOUT_SECS", "1") };
let health_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let health_done_clone = health_done.clone();
let entry = dummy_entry("test-blocking", "/Volumes/SSD1/blocking-idx");
let restore_task = tokio::spawn(restore_one_index_bounded(entry, |_e| async {
std::thread::sleep(std::time::Duration::from_secs(2));
}));
let health_task = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
health_done_clone.store(true, std::sync::atomic::Ordering::SeqCst);
});
let _ = health_task.await;
let result = restore_task.await.expect("restore task must not panic");
unsafe { std::env::remove_var("TRUSTY_WARMBOOT_INDEX_TIMEOUT_SECS") };
assert!(
health_done.load(std::sync::atomic::Ordering::SeqCst),
"async /health proxy task must complete while blocking restore is running; \
if this fails, the blocking restore is freezing an async worker (issue #718)"
);
assert!(
!result,
"restore with a blocking sleep exceeding the timeout must return false"
);
}
}