Robustness: recover from poisoned locks instead of cascading panics (2.5)

The web server holds several long-lived Mutexes in WebState (library,
downloader, watched, flags, positions, sessions, …). Every access used
.lock().unwrap(), so a single panic while any one was held would poison
it and turn EVERY subsequent request into a panic — one handler's bug
killing the whole server until restart.

New util::LockExt::lock_recover() acquires the guard but recovers the
inner data via PoisonError::into_inner() on poison. Our critical
sections are short and don't leave half-updated invariants, so the data
stays usable and a stuck request beats a stuck process. Applied to all
70 lock sites in web.rs.

Test poisons a mutex from a panicking thread and confirms lock_recover
still returns usable data. 96 pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-06 23:49:56 -07:00
parent 3cd4827ff0
commit 111d09b130
3 changed files with 120 additions and 70 deletions

View file

@ -43,6 +43,7 @@ use tower_http::services::ServeDir;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::password_hash::SaltString;
use crate::util::LockExt;
use crate::config::Config;
use crate::database::Database;
use crate::downloader::{DownloadQuality, Downloader, JobState};
@ -868,7 +869,7 @@ fn session_token_from_headers(headers: &HeaderMap) -> Option<String> {
/// `sessions` map doesn't grow without bound for users who never log out.
fn is_authed(state: &WebState, headers: &HeaderMap) -> bool {
let Some(token) = session_token_from_headers(headers) else { return false };
let mut sessions = state.sessions.lock().unwrap();
let mut sessions = state.sessions.lock_recover();
let now = std::time::Instant::now();
// Lazy prune: drop anything older than the TTL.
sessions.retain(|_, issued| now.duration_since(*issued) < SESSION_TTL);
@ -933,7 +934,7 @@ async fn post_login(
let now = std::time::Instant::now();
// Check lockout first.
{
let mut attempts = state.login_attempts.lock().unwrap();
let mut attempts = state.login_attempts.lock_recover();
// GC entries whose lockout has elapsed.
attempts.retain(|_, a| a.until.map_or(true, |u| u > now));
if let Some(a) = attempts.get(&ip) {
@ -951,7 +952,7 @@ async fn post_login(
return (StatusCode::OK, "no password set").into_response();
};
if !verify_password(&body.password, &hash) {
let mut attempts = state.login_attempts.lock().unwrap();
let mut attempts = state.login_attempts.lock_recover();
let entry = attempts.entry(ip).or_insert(LoginAttempt { failures: 0, until: None });
entry.failures += 1;
if entry.failures >= LOGIN_LOCKOUT_AFTER {
@ -961,10 +962,10 @@ async fn post_login(
return (StatusCode::UNAUTHORIZED, "invalid password").into_response();
}
// Success: reset the failure counter for this IP.
state.login_attempts.lock().unwrap().remove(&ip);
state.login_attempts.lock_recover().remove(&ip);
let token = generate_session_token();
state.sessions.lock().unwrap().insert(token.clone(), now);
state.sessions.lock_recover().insert(token.clone(), now);
let cookie = session_cookie(&token, &headers, SESSION_TTL.as_secs());
([(header::SET_COOKIE, cookie)], StatusCode::OK).into_response()
}
@ -975,7 +976,7 @@ async fn post_logout(
headers: HeaderMap,
) -> Response {
if let Some(token) = session_token_from_headers(&headers) {
state.sessions.lock().unwrap().remove(&token);
state.sessions.lock_recover().remove(&token);
}
let cookie = session_cookie("", &headers, 0);
([(header::SET_COOKIE, cookie)], StatusCode::OK).into_response()
@ -1079,7 +1080,7 @@ async fn get_library(
// reliably). Serializing a multi-MB library JSON for every reload
// burns measurable CPU on large installations.
{
let cache = state.library_body_cache.lock().unwrap();
let cache = state.library_body_cache.lock_recover();
if let Some((cached_ver, body)) = cache.as_ref() {
if *cached_ver == version {
let body = body.clone();
@ -1105,7 +1106,7 @@ async fn get_library(
let body = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
if post_version == version {
let body_arc = std::sync::Arc::new(body.clone());
*state.library_body_cache.lock().unwrap() = Some((version, body_arc));
*state.library_body_cache.lock_recover() = Some((version, body_arc));
}
(
[
@ -1118,10 +1119,10 @@ async fn get_library(
}
async fn build_library_payload(state: &Arc<WebState>) -> LibraryResponse {
let lib = state.library.lock().unwrap();
let watched = state.watched.lock().unwrap();
let positions = state.positions.lock().unwrap();
let flags = state.flags.lock().unwrap();
let lib = state.library.lock_recover();
let watched = state.watched.lock_recover();
let positions = state.positions.lock_recover();
let flags = state.flags.lock_recover();
// file_url() now resolves relative to library_root (= parent of
// channels_root) so non-YouTube platforms are reachable at
// `/files/<platform>/<creator>/<video>`.
@ -1208,7 +1209,7 @@ async fn build_library_payload(state: &Arc<WebState>) -> LibraryResponse {
async fn get_music(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let music_root = {
let dl = state.downloader.lock().unwrap();
let dl = state.downloader.lock_recover();
dl.music_root()
};
let tracks = library::scan_music(&music_root);
@ -1262,7 +1263,7 @@ async fn ws_progress_handler(
// Initial snapshot so the UI shows the current state immediately
// (without waiting up to 5s for the next idle broadcast tick).
let initial = {
let mut dl = state.downloader.lock().unwrap();
let mut dl = state.downloader.lock_recover();
dl.poll();
let queued = dl.pending_snapshots().into_iter()
.map(|(label, url)| QueuedSnapshot { label, url })
@ -1298,7 +1299,7 @@ async fn ws_progress_handler(
}
async fn get_progress(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let mut dl = state.downloader.lock().unwrap();
let mut dl = state.downloader.lock_recover();
dl.poll();
let queued = dl.pending_snapshots().into_iter()
.map(|(label, url)| QueuedSnapshot { label, url })
@ -1317,7 +1318,7 @@ async fn post_download(
if url.is_empty() {
return (StatusCode::BAD_REQUEST, "empty URL").into_response();
}
let mut dl = state.downloader.lock().unwrap();
let mut dl = state.downloader.lock_recover();
if body.quality == "music" {
dl.start_music(url);
} else {
@ -1342,7 +1343,7 @@ async fn post_watched(
Path(video_id): Path<String>,
) -> impl IntoResponse {
let db = &state.db;
let mut watched = state.watched.lock().unwrap();
let mut watched = state.watched.lock_recover();
let now_watched = !watched.contains(&video_id);
if let Err(e) = db.set_watched(&video_id, now_watched) {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
@ -1354,7 +1355,7 @@ async fn post_watched(
}
async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let cfg = state.config.lock().unwrap();
let cfg = state.config.lock_recover();
let source_url = cfg.web.source_url.clone();
let port = cfg.web.port;
let current_bind = cfg.web.bind.clone();
@ -1372,7 +1373,7 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
drop(cfg);
let scheduler_next_check_secs = if scheduler_enabled {
let last = *state.last_scheduled_check.lock().unwrap();
let last = *state.last_scheduled_check.lock_recover();
let interval_secs = scheduler_interval_hours as u64 * 3600;
Some(match last {
None => 0,
@ -1419,7 +1420,7 @@ async fn post_settings(
Json(body): Json<SettingsPayload>,
) -> impl IntoResponse {
state.transcode.store(body.transcode, Ordering::Relaxed);
let mut cfg = state.config.lock().unwrap();
let mut cfg = state.config.lock_recover();
cfg.web.transcode = body.transcode;
if let Some(new_mode) = &body.bind_mode {
@ -1477,7 +1478,7 @@ async fn post_settings(
// Apply the new concurrency limit and binary choices to the live downloader.
{
let mut dl = state.downloader.lock().unwrap();
let mut dl = state.downloader.lock_recover();
if body.max_concurrent > 0 {
dl.max_concurrent = body.max_concurrent;
}
@ -1501,15 +1502,15 @@ async fn post_settings(
return (StatusCode::INTERNAL_SERVER_ERROR, format!("db error: {e}")).into_response();
}
// Password changed: drop all existing sessions so they must re-authenticate.
state.sessions.lock().unwrap().clear();
state.sessions.lock_recover().clear();
refresh_password_cache(&state);
}
let plex_library_path = state.config.lock().unwrap().plex.library_path.as_deref()
let plex_library_path = state.config.lock_recover().plex.library_path.as_deref()
.map(|p| p.display().to_string());
let download_password_required = state.password_required_cache.load(Ordering::Relaxed);
let scheduler_next_check_secs = if scheduler_enabled {
let last = *state.last_scheduled_check.lock().unwrap();
let last = *state.last_scheduled_check.lock_recover();
let interval_secs = scheduler_interval_hours as u64 * 3600;
Some(match last {
None => 0,
@ -1579,7 +1580,7 @@ async fn get_transcode(
Path(id): Path<String>,
) -> Response {
let path = {
let lib = state.library.lock().unwrap();
let lib = state.library.lock_recover();
find_video_path(&lib, &id)
};
let Some(path) = path else {
@ -1656,7 +1657,7 @@ async fn post_resume(
// "Continue watching" set). Plain position updates fire every few seconds
// during playback — invalidating the cache on each would defeat the
// ETag optimisation.
let mut positions = state.positions.lock().unwrap();
let mut positions = state.positions.lock_recover();
let was_resumable = positions.get(&video_id).copied().is_some_and(|p| p > 3.0);
let db = &state.db;
if body.position > 3.0 {
@ -1685,7 +1686,7 @@ async fn get_preview(
if url.is_empty() {
return (StatusCode::BAD_REQUEST, "no url").into_response();
}
let use_bundled = state.config.lock().unwrap().backup.use_bundled_ytdlp;
let use_bundled = state.config.lock_recover().backup.use_bundled_ytdlp;
if use_bundled {
crate::ytdlp_bin::ensure_bundled_executable();
}
@ -1706,7 +1707,7 @@ async fn get_preview(
.arg("--no-warnings");
// Mirror Downloader::apply_cookie_flags so the preview honors the same
// cookies precedence (file > browser fallback).
let browser = state.config.lock().unwrap().player.browser.clone();
let browser = state.config.lock_recover().player.browser.clone();
if std::path::Path::new("cookies.txt").exists() {
cmd.arg("--cookies").arg("cookies.txt");
} else if !browser.is_empty() && browser != "none" {
@ -1751,7 +1752,7 @@ async fn get_preview(
}
async fn post_clear_jobs(State(state): State<Arc<WebState>>) -> impl IntoResponse {
state.downloader.lock().unwrap().clear_finished();
state.downloader.lock_recover().clear_finished();
(StatusCode::OK, "cleared")
}
@ -1759,7 +1760,7 @@ async fn post_remove_job(
State(state): State<Arc<WebState>>,
Path(idx): Path<usize>,
) -> impl IntoResponse {
state.downloader.lock().unwrap().remove_job(idx);
state.downloader.lock_recover().remove_job(idx);
(StatusCode::OK, "removed")
}
@ -1768,7 +1769,7 @@ async fn get_chapters(
Path(video_id): Path<String>,
) -> impl IntoResponse {
let info_path = {
let lib = state.library.lock().unwrap();
let lib = state.library.lock_recover();
find_video_info_path(&lib, &video_id)
};
let Some(info_path) = info_path else {
@ -1807,7 +1808,7 @@ async fn get_comments(
Path(video_id): Path<String>,
) -> impl IntoResponse {
let info_path = {
let lib = state.library.lock().unwrap();
let lib = state.library.lock_recover();
find_video_info_path(&lib, &video_id)
};
let Some(info_path) = info_path else {
@ -1850,7 +1851,7 @@ async fn get_metadata(
Path(video_id): Path<String>,
) -> impl IntoResponse {
let info_path = {
let lib = state.library.lock().unwrap();
let lib = state.library.lock_recover();
find_video_info_path(&lib, &video_id)
};
let Some(info_path) = info_path else {
@ -1870,7 +1871,7 @@ async fn get_description(
State(state): State<Arc<WebState>>,
Path(video_id): Path<String>,
) -> impl IntoResponse {
let lib = state.library.lock().unwrap();
let lib = state.library.lock_recover();
if let Some((v, _)) = library::find_video(&lib, &video_id) {
let text = v.description_path.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
@ -1890,9 +1891,9 @@ async fn post_rescan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
}
// Refresh watched from DB after rescan
if let Ok(w) = state.db.get_watched() {
*state.watched.lock().unwrap() = w;
*state.watched.lock_recover() = w;
}
*state.library.lock().unwrap() = new_lib;
*state.library.lock_recover() = new_lib;
bump_library_version(&state);
(StatusCode::OK, "rescanned")
}
@ -1900,13 +1901,13 @@ async fn post_rescan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
/// `POST /api/plex/generate` — generate (or refresh) the Plex symlink tree.
async fn post_plex_generate(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let plex_root = {
let cfg = state.config.lock().unwrap();
let cfg = state.config.lock_recover();
cfg.plex.library_path.clone()
};
let Some(plex_root) = plex_root else {
return (StatusCode::BAD_REQUEST, "no plex.library_path configured").into_response();
};
let lib = state.library.lock().unwrap();
let lib = state.library.lock_recover();
let result = crate::plex::generate(&lib, &plex_root);
drop(lib);
Json(serde_json::json!({
@ -1917,7 +1918,7 @@ async fn post_plex_generate(State(state): State<Arc<WebState>>) -> impl IntoResp
/// `GET /api/maintenance/scan` — report duplicate videos and missing assets.
async fn get_maintenance_scan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let lib = state.library.lock().unwrap();
let lib = state.library.lock_recover();
let report = maintenance::scan(&state.library_root, &lib);
Json(report)
}
@ -1942,7 +1943,7 @@ async fn post_maintenance_remove(
if let Ok(folder_map) = state.db.get_all_channel_assignments() {
library::apply_channel_folders(&mut new_lib, &folder_map);
}
*state.library.lock().unwrap() = new_lib;
*state.library.lock_recover() = new_lib;
bump_library_version(&state);
Json(serde_json::json!({ "removed": removed, "errors": errors }))
}
@ -1953,13 +1954,13 @@ async fn post_maintenance_repair(
Path(id): Path<String>,
) -> impl IntoResponse {
let target = {
let lib = state.library.lock().unwrap();
let lib = state.library.lock_recover();
maintenance::locate(&lib, &id)
};
let Some((dir, stem)) = target else {
return (StatusCode::NOT_FOUND, "video not found in library").into_response();
};
state.downloader.lock().unwrap().repair(&id, &dir, &stem);
state.downloader.lock_recover().repair(&id, &dir, &stem);
(StatusCode::ACCEPTED, "repair queued").into_response()
}
@ -2047,7 +2048,7 @@ async fn delete_folder(
match state.db.delete_folder(id) {
Ok(()) => {
// Mirror onto the live library snapshot.
let mut lib = state.library.lock().unwrap();
let mut lib = state.library.lock_recover();
for ch in lib.iter_mut() {
if ch.folder_id == Some(id) {
ch.folder_id = None;
@ -2143,13 +2144,13 @@ async fn post_restore_db(
// Refresh in-memory caches so the next /api/library reflects the new
// watched / position / flag rows without waiting for an app restart.
if let Ok(w) = state.db.get_watched() {
*state.watched.lock().unwrap() = w;
*state.watched.lock_recover() = w;
}
if let Ok(p) = state.db.get_positions() {
*state.positions.lock().unwrap() = p;
*state.positions.lock_recover() = p;
}
if let Ok(f) = state.db.get_video_flags() {
*state.flags.lock().unwrap() = f;
*state.flags.lock_recover() = f;
}
// Bump the library version so cached /api/library responses revalidate.
bump_library_version(&state);
@ -2168,11 +2169,11 @@ async fn post_check_folder(
State(state): State<Arc<WebState>>,
Path(folder_id): Path<i64>,
) -> impl IntoResponse {
if state.downloader.lock().unwrap().any_running() {
if state.downloader.lock_recover().any_running() {
return (StatusCode::CONFLICT, "downloads already running").into_response();
}
let scheduled: Vec<(String, crate::download_options::DownloadOptions)> =
state.library.lock().unwrap()
state.library.lock_recover()
.iter()
.filter(|ch| ch.folder_id == Some(folder_id))
.map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone()))
@ -2181,7 +2182,7 @@ async fn post_check_folder(
return (StatusCode::OK, "no channels in folder").into_response();
}
let count = scheduled.len();
let mut dl = state.downloader.lock().unwrap();
let mut dl = state.downloader.lock_recover();
for (url, opts) in scheduled {
let info = classify_url(&url);
let quality = opts.quality.unwrap_or(DownloadQuality::Best);
@ -2201,7 +2202,7 @@ async fn post_assign_folder(
return (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}")).into_response();
}
{
let mut lib = state.library.lock().unwrap();
let mut lib = state.library.lock_recover();
for ch in lib.iter_mut() {
if ch.platform.dir_name() == platform && ch.name == handle {
ch.folder_id = body.folder_id;
@ -2238,7 +2239,7 @@ async fn post_video_flag(
// Mirror into the in-memory bundle so the next GET /api/library reflects
// the change without waiting for a rescan.
{
let mut bundle = state.flags.lock().unwrap();
let mut bundle = state.flags.lock_recover();
let target = match set_ref {
"bookmark" => &mut bundle.bookmark,
"favourite" => &mut bundle.favourite,
@ -2318,7 +2319,7 @@ async fn post_channel_options(
// Push the new options onto the live library snapshot so a re-check
// triggered before the next rescan still sees them.
{
let mut lib = state.library.lock().unwrap();
let mut lib = state.library.lock_recover();
for ch in lib.iter_mut() {
if ch.platform.dir_name() == platform && ch.name == handle {
ch.download_options = body.clone();
@ -2340,7 +2341,7 @@ async fn delete_channel_options(
return (StatusCode::INTERNAL_SERVER_ERROR, format!("db: {e}")).into_response();
}
{
let mut lib = state.library.lock().unwrap();
let mut lib = state.library.lock_recover();
for ch in lib.iter_mut() {
if ch.platform.dir_name() == platform && ch.name == handle {
ch.download_options = Default::default();
@ -2353,13 +2354,13 @@ async fn delete_channel_options(
}
async fn post_scheduler_run(State(state): State<Arc<WebState>>) -> impl IntoResponse {
if state.downloader.lock().unwrap().any_running() {
if state.downloader.lock_recover().any_running() {
return (StatusCode::CONFLICT, "downloads already running").into_response();
}
// Snapshot (url, options) pairs so we can iterate without holding the
// library lock through start().
let scheduled: Vec<(String, crate::download_options::DownloadOptions)> =
state.library.lock().unwrap()
state.library.lock_recover()
.iter()
.map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone()))
.collect();
@ -2367,22 +2368,22 @@ async fn post_scheduler_run(State(state): State<Arc<WebState>>) -> impl IntoResp
return (StatusCode::OK, "no channels to check").into_response();
}
let count = scheduled.len();
let mut dl = state.downloader.lock().unwrap();
let mut dl = state.downloader.lock_recover();
for (url, opts) in scheduled {
let info = classify_url(&url);
let quality = opts.quality.unwrap_or(DownloadQuality::Best);
dl.start(url, &info, true, quality, false, Some(&opts));
}
*state.last_scheduled_check.lock().unwrap() = Some(std::time::Instant::now());
*state.last_scheduled_check.lock_recover() = Some(std::time::Instant::now());
(StatusCode::ACCEPTED, format!("started {count} channel checks")).into_response()
}
/// `GET /api/stats` — aggregate library statistics (totals, top channels,
/// per-year upload histogram, per-week download activity).
async fn get_stats(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let lib = state.library.lock().unwrap();
let watched = state.watched.lock().unwrap();
let positions = state.positions.lock().unwrap();
let lib = state.library.lock_recover();
let watched = state.watched.lock_recover();
let positions = state.positions.lock_recover();
let report = crate::stats::build(&lib, &watched, &positions, crate::stats::now_unix());
Json(report)
}
@ -2390,7 +2391,7 @@ async fn get_stats(State(state): State<Arc<WebState>>) -> impl IntoResponse {
/// `POST /api/ytdlp/update` — download (or update) the bundled yt-dlp + deno
/// binaries. Streams output through a regular [`Job`] entry.
async fn post_ytdlp_update(State(state): State<Arc<WebState>>) -> impl IntoResponse {
state.downloader.lock().unwrap().start_ytdlp_update();
state.downloader.lock_recover().start_ytdlp_update();
(StatusCode::ACCEPTED, "started bundled yt-dlp update").into_response()
}
@ -2405,7 +2406,7 @@ async fn post_pot_update(State(state): State<Arc<WebState>>) -> impl IntoRespons
"install the bundled yt-dlp first — the POT plugin gets pip-installed into its venv",
).into_response();
}
state.downloader.lock().unwrap().start_pot_provider_update();
state.downloader.lock_recover().start_pot_provider_update();
(StatusCode::ACCEPTED, "started POT provider install").into_response()
}
@ -2570,7 +2571,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
loop {
// Pick interval based on whether there's anything to report on.
let interval_ms = {
let dl = progress_state.downloader.lock().unwrap();
let dl = progress_state.downloader.lock_recover();
if dl.any_running() || dl.pending_count() > 0 { 500 } else { 5_000 }
};
tokio::time::sleep(std::time::Duration::from_millis(interval_ms)).await;
@ -2581,7 +2582,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
// Drive the same poll() that /api/progress does so the snapshot
// includes the latest stdout/stderr lines.
let snapshot = {
let mut dl = progress_state.downloader.lock().unwrap();
let mut dl = progress_state.downloader.lock_recover();
dl.poll();
let queued = dl.pending_snapshots().into_iter()
.map(|(label, url)| QueuedSnapshot { label, url })
@ -2605,33 +2606,33 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
loop {
tick.tick().await;
let (enabled, interval_hours) = {
let cfg = sched_state.config.lock().unwrap();
let cfg = sched_state.config.lock_recover();
(cfg.scheduler.enabled, cfg.scheduler.interval_hours)
};
if !enabled { continue; }
if sched_state.downloader.lock().unwrap().any_running() { continue; }
if sched_state.downloader.lock_recover().any_running() { continue; }
// Clamp interval defensively. A manually edited config.toml with 0
// (or accidentally tiny value) would otherwise trigger every tick.
let safe_hours = interval_hours.max(1);
let interval_dur = std::time::Duration::from_secs(safe_hours as u64 * 3600);
let due = {
let last = *sched_state.last_scheduled_check.lock().unwrap();
let last = *sched_state.last_scheduled_check.lock_recover();
last.map_or(true, |t| t.elapsed() >= interval_dur)
};
if !due { continue; }
let scheduled: Vec<(String, crate::download_options::DownloadOptions)> =
sched_state.library.lock().unwrap()
sched_state.library.lock_recover()
.iter()
.map(|ch| (crate::downloader::recheck_url(ch), ch.download_options.clone()))
.collect();
if scheduled.is_empty() { continue; }
let mut dl = sched_state.downloader.lock().unwrap();
let mut dl = sched_state.downloader.lock_recover();
for (url, opts) in scheduled {
let info = classify_url(&url);
let quality = opts.quality.unwrap_or(DownloadQuality::Best);
dl.start(url, &info, true, quality, false, Some(&opts));
}
*sched_state.last_scheduled_check.lock().unwrap() = Some(std::time::Instant::now());
*sched_state.last_scheduled_check.lock_recover() = Some(std::time::Instant::now());
}
});