C2(hephd-self-update): impl self-update poll loop (notify-only)

Add a ReleaseSource trait (real ForgeReleaseSource over HTTP; injectable
for tests), check_release() returning a CheckOutcome
(UpToDate/UpdateAvailable/Failed) that never errors so a flaky forge
can't stall the daemon, and run_poll_loop() that ticks on the configured
interval and logs when a newer release is available. spawn_self_update_loop
now spawns the real poller. Detection is unit-tested with a stubbed source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-04 13:42:21 -07:00
commit 9fb200fe24
2 changed files with 114 additions and 4 deletions

View file

@ -85,10 +85,116 @@ pub async fn fetch_latest_tag(http: &reqwest::Client, url: &str) -> Result<Strin
parse_latest_tag(&body)
}
/// Where "the latest release tag" comes from. Injectable so the poll loop can
/// be exercised without hitting the network (real impl: [`ForgeReleaseSource`]).
pub trait ReleaseSource: Send + Sync + 'static {
fn latest_tag(&self) -> impl std::future::Future<Output = Result<String>> + Send;
}
/// The production source: the forge's `releases/latest` over HTTP.
pub struct ForgeReleaseSource {
http: reqwest::Client,
url: String,
}
impl ForgeReleaseSource {
/// Source backed by the daemon's shared client, hitting [`RELEASES_LATEST_URL`].
pub fn new(http: reqwest::Client) -> Self {
Self {
http,
url: RELEASES_LATEST_URL.to_string(),
}
}
}
impl ReleaseSource for ForgeReleaseSource {
async fn latest_tag(&self) -> Result<String> {
fetch_latest_tag(&self.http, &self.url).await
}
}
/// The result of one self-update check — kept separate from logging so it can be
/// asserted in tests.
#[derive(Debug, PartialEq, Eq)]
pub enum CheckOutcome {
/// The running version is at or ahead of the latest release.
UpToDate,
/// A strictly newer release exists, named by this tag (e.g. `v1.0.4`).
UpdateAvailable(String),
/// The check failed (forge unreachable, bad body, unparseable version).
Failed(String),
}
/// Run one check against `source`, comparing the latest tag to `current`. Never
/// returns `Err` — a failure is folded into [`CheckOutcome::Failed`] so the loop
/// keeps going (a flaky forge must never crash or stall the daemon).
pub async fn check_release<S: ReleaseSource>(source: &S, current: &str) -> CheckOutcome {
match source.latest_tag().await {
Ok(tag) => match update_available(current, &tag) {
Ok(true) => CheckOutcome::UpdateAvailable(tag),
Ok(false) => CheckOutcome::UpToDate,
Err(e) => CheckOutcome::Failed(e.to_string()),
},
Err(e) => CheckOutcome::Failed(e.to_string()),
}
}
/// The background poll loop (notify-only for now): tick on `interval`, check for
/// a newer release, and log the outcome. Runs forever; spawned as a task.
pub async fn run_poll_loop<S: ReleaseSource>(source: S, interval: Duration, current: &'static str) {
let mut tick = tokio::time::interval(interval);
loop {
tick.tick().await;
match check_release(&source, current).await {
CheckOutcome::UpdateAvailable(tag) => {
tracing::info!(%tag, current, "self-update: newer release available")
}
CheckOutcome::UpToDate => tracing::debug!(current, "self-update: up to date"),
CheckOutcome::Failed(e) => tracing::warn!("self-update: release check failed: {e}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A canned release source for deterministic loop/decision tests.
struct FakeSource(Result<String, &'static str>);
impl ReleaseSource for FakeSource {
async fn latest_tag(&self) -> Result<String> {
self.0.clone().map_err(|e| anyhow::anyhow!(e))
}
}
#[tokio::test]
async fn check_release_reports_outcomes_from_a_stubbed_source() {
// Newer release available.
let s = FakeSource(Ok("v1.0.4".into()));
assert_eq!(
check_release(&s, "1.0.3 (sha)").await,
CheckOutcome::UpdateAvailable("v1.0.4".into())
);
// Already current.
let s = FakeSource(Ok("v1.0.3".into()));
assert_eq!(
check_release(&s, "1.0.3 (sha)").await,
CheckOutcome::UpToDate
);
// Fetch failure → folded into Failed, never a panic/Err.
let s = FakeSource(Err("forge unreachable"));
assert!(matches!(
check_release(&s, "1.0.3 (sha)").await,
CheckOutcome::Failed(_)
));
// Malformed tag → Failed.
let s = FakeSource(Ok("not-a-tag".into()));
assert!(matches!(
check_release(&s, "1.0.3 (sha)").await,
CheckOutcome::Failed(_)
));
}
#[test]
fn config_defaults_interval_and_honors_override() {
assert_eq!(SelfUpdateConfig::new(None).interval, DEFAULT_INTERVAL);

View file

@ -21,7 +21,7 @@ use heph_core::Store;
use crate::oauth::{self, TokenStore};
use crate::rpc::{self, Request, Response, RpcError, INTERNAL_ERROR, PARSE_ERROR};
use crate::selfupdate::SelfUpdateConfig;
use crate::selfupdate::{self, SelfUpdateConfig};
use crate::sync::{self, SharedStore};
/// How a spoke obtains the bearer token it presents to its hub (tech-spec §13).
@ -116,18 +116,22 @@ impl Daemon {
self.ctx.store.clone()
}
/// If self-update is enabled, start its background poller. For now this only
/// announces the mode at startup; the polling + apply cycle is wired in
/// later self-update work. No-op when the mode is off.
/// If self-update is enabled, spawn its background poller: every
/// `cfg.interval` it checks the forge for a newer release and (for now) logs
/// when one is available. No-op when the mode is off.
pub fn spawn_self_update_loop(&self) {
let Some(cfg) = self.ctx.self_update.clone() else {
return;
};
let source = selfupdate::ForgeReleaseSource::new(self.ctx.http.clone());
tracing::info!(
interval_secs = cfg.interval.as_secs(),
current = heph_core::VERSION,
"self-update enabled"
);
tokio::spawn(async move {
selfupdate::run_poll_loop(source, cfg.interval, heph_core::VERSION).await;
});
}
/// If this is a spoke (`hub_url` set), spawn a background task that syncs the