fix(update): run periodic update check in daemon so hints actually fire (#76)

* fix(update): run periodic update check in daemon so hints actually fire

The CLI spawned a detached thread to refresh ~/.bsk/update-check.json,
but the process exits as soon as the command finishes, killing the
thread before the fetch could complete — so the cache was never
written and the new-version hint never showed.

Move the periodic check into the daemon: a tokio task ticks every
30 minutes (down from 24h), refreshes the cache when missing/stale,
and logs failures at warn/info into the daemon log. The CLI path is
now read-only: it prints the hint only from a fresh cache and never
spawns threads or touches the network.

* fix(update): decouple daemon refresh window so checks truly run every 30min

The refresh decision reused the 30min tick interval as its freshness
window, and with is_fresh()'s inclusive bound a cache written just
after tick N still counted as fresh at tick N+1 — so in steady state
the daemon only refetched every other tick (~60min).

Judge staleness against DAEMON_REFRESH_WINDOW (25min = 5/6 of the
tick) instead: every 30min tick now finds the cache stale and really
refreshes, while a daemon restarted with a younger-than-25min cache
still skips its first tick. CLI hint freshness semantics
(UPDATE_CHECK_INTERVAL = 30min) are unchanged.
This commit is contained in:
BB-fat
2026-08-12 14:45:34 +08:00
committed by GitHub
parent 238a98476e
commit 89200975f3
3 changed files with 228 additions and 22 deletions
+65
View File
@@ -220,6 +220,7 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
let state = Arc::new(DaemonState::new(cfg.clone()));
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state));
let update_check_task = spawn_update_check_task();
let ws_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), cfg.ws_port);
let ws_handle = ws::WsServer::new(Arc::clone(&state))
.bind(ws_addr)
@@ -383,6 +384,8 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
let _ = session_idle_task.await;
browser_liveness_task.abort();
let _ = browser_liveness_task.await;
update_check_task.abort();
let _ = update_check_task.await;
ws_handle.shutdown.notify_waiters();
let _ = ws_handle.task.await;
@@ -491,6 +494,68 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task:
})
}
/// Spawn the periodic update check owned by the production daemon.
///
/// The daemon is the only writer of `~/.bsk/update-check.json`; CLI
/// commands only read it to print the "new version available" hint
/// (see [`crate::cli::update::print_update_hint_from_cache`]). The first
/// tick of `tokio::time::interval` is immediate, so the check runs right
/// after startup and then every
/// [`crate::cli::update::UPDATE_CHECK_INTERVAL`]. Each tick re-reads the
/// cache and skips the network fetch while it is still fresh within
/// [`crate::cli::update::DAEMON_REFRESH_WINDOW`] (25min — shorter than
/// the 30min tick, so steady state really refreshes on every tick
/// instead of every other one). The task
/// loops forever; shutdown aborts it like the other background tasks, so
/// it never delays daemon exit (an in-flight fetch is bounded by the
/// update client's own timeout and detached on abort).
pub(crate) fn spawn_update_check_task() -> tokio::task::JoinHandle<()> {
use crate::cli::update;
tokio::spawn(async move {
let cache_path = match paths::update_check_path() {
Ok(path) => path,
Err(err) => {
warn!(error = %err, "periodic update check disabled: cannot resolve cache path");
return;
}
};
let mut ticker = tokio::time::interval(update::UPDATE_CHECK_INTERVAL);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
ticker.tick().await;
let needs_refresh = match update::read_update_cache(&cache_path) {
Ok(cache) => update::cache_needs_refresh(
cache.as_ref(),
update::now_epoch_secs(),
update::DAEMON_REFRESH_WINDOW,
),
Err(err) => {
warn!(error = %err, "update cache unreadable; will refresh it");
true
}
};
if !needs_refresh {
debug!("update cache still fresh; skipping update check");
continue;
}
let result = {
let cache_path = cache_path.clone();
tokio::task::spawn_blocking(move || update::refresh_update_cache(&cache_path)).await
};
match result {
Ok(Ok(Some(hint))) => info!(%hint, "periodic update check found a new version"),
Ok(Ok(None)) => info!("periodic update check refreshed cache (already up to date)"),
Ok(Err(err)) => warn!(error = %err, "periodic update check failed"),
Err(err) => warn!(error = %err, "periodic update check task panicked"),
}
}
})
}
fn record_activity(activity: &Arc<Mutex<Instant>>) {
if let Ok(mut a) = activity.lock() {
*a = Instant::now();