diff --git a/CHANGELOG.md b/CHANGELOG.md index 994e3b931..f8c637edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `zeph-plugins`: replaced blocking `std::fs::write` / `std::fs::read_to_string` calls in the async `update_one_plugin` with `tokio::fs` equivalents, preventing Tokio thread starvation during concurrent auto-update checks (closes #4560). +- `apply_response_cache`: hourly cleanup background task now exits cleanly when the session shuts + down. The loop uses `tokio::select!` on a `CancellationToken` child of `mem_cancel`, which is + already cancelled via `shutdown_rx` at session teardown. Closes #4572. +- `runner`: TUI early-status-forwarder `JoinHandle` annotated as intentionally dropped at block + end (self-terminating when the channel closes). Removes misleading bare `let _` and satisfies + `clippy::let_underscore_future`. Closes #4571. ### Added diff --git a/src/agent_setup.rs b/src/agent_setup.rs index 7103f83d6..4617ae135 100644 --- a/src/agent_setup.rs +++ b/src/agent_setup.rs @@ -668,6 +668,7 @@ pub(crate) fn apply_response_cache( ttl_secs: u64, semantic_cache_enabled: bool, embed_model: String, + cancel: tokio_util::sync::CancellationToken, ) -> Agent { if !enabled { if semantic_cache_enabled { @@ -681,11 +682,18 @@ pub(crate) fn apply_response_cache( let mut interval = tokio::time::interval(std::time::Duration::from_hours(1)); interval.tick().await; // skip immediate first tick loop { - interval.tick().await; - match cache_clone.cleanup(&embed_model).await { - Ok(n) if n > 0 => tracing::debug!("cleaned up {n} cache entries"), - Ok(_) => {} - Err(e) => tracing::warn!("response cache cleanup failed: {e:#}"), + tokio::select! { + () = cancel.cancelled() => { + tracing::debug!("response cache cleanup loop: shutting down"); + break; + } + _ = interval.tick() => { + match cache_clone.cleanup(&embed_model).await { + Ok(n) if n > 0 => tracing::debug!("cleaned up {n} cache entries"), + Ok(_) => {} + Err(e) => tracing::warn!("response cache cleanup failed: {e:#}"), + } + } } } }); @@ -1702,7 +1710,9 @@ mod tests { let db_url = format!("sqlite:{}", tmp.path().display()); let pool = zeph_db::sqlx::SqlitePool::connect(&db_url).await.unwrap(); let agent = make_agent(); - let result = apply_response_cache(agent, false, pool, 300, false, "embed-model".into()); + let cancel = tokio_util::sync::CancellationToken::new(); + let result = + apply_response_cache(agent, false, pool, 300, false, "embed-model".into(), cancel); drop(result); } @@ -1712,7 +1722,9 @@ mod tests { let db_url = format!("sqlite:{}", tmp.path().display()); let pool = zeph_db::sqlx::SqlitePool::connect(&db_url).await.unwrap(); let agent = make_agent(); - let result = apply_response_cache(agent, true, pool, 300, false, "embed-model".into()); + let cancel = tokio_util::sync::CancellationToken::new(); + let result = + apply_response_cache(agent, true, pool, 300, false, "embed-model".into(), cancel); drop(result); } diff --git a/src/runner.rs b/src/runner.rs index 29af2cabb..c63a50371 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -954,6 +954,7 @@ pub(crate) async fn run(cli: Cli) -> anyhow::Result<()> { // dropped at the end of bootstrap. The TUI thread observes the channel close and // shuts down independently, so explicit abort is not needed. Dropping the handle // is intentional — we have no cleanup to do on the bootstrap error path here. + // EXEMPT: self-terminating on channel close — handle dropped intentionally at block end let _early_status_forwarder = tokio::spawn(crate::tui_bridge::forward_status_to_tui( status_rx, early.agent_tx.clone(), @@ -2459,6 +2460,7 @@ pub(crate) async fn run(cli: Cli) -> anyhow::Result<()> { config.llm.response_cache_ttl_secs, config.llm.semantic_cache_enabled, crate::bootstrap::effective_embedding_model(config), + mem_cancel.child_token(), ); let agent = agent_setup::apply_cost_tracker(agent, config); let agent = agent_setup::apply_summary_provider(agent, summary_provider);