generated from eblume/project-template
C2(hephd-self-update): impl self-restart-after-update (injectable Restarter)
Add a Restarter trait + ProcessRestarter (exit 0 so launchd KeepAlive / systemd Restart=always respawn the new binary). apply_update now installs then restarts, and the restart fires only on a successful install. Wired into the poll loop. Unit-tested with fake installer+restarter: restart on success, no restart after a failed install. Real process exit is never run in tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e7cb86efdf
commit
bdcf4171a4
2 changed files with 77 additions and 16 deletions
|
|
@ -182,14 +182,39 @@ impl Installer for CargoInstaller {
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply a detected update: install the binaries for `tag`. The blocking install
|
||||
/// runs on the blocking pool so it never stalls the async runtime. (Restarting
|
||||
/// onto the new binary is layered on by the self-restart step.)
|
||||
pub async fn apply_update(installer: Arc<dyn Installer>, tag: &str) -> Result<()> {
|
||||
let tag = tag.to_string();
|
||||
tokio::task::spawn_blocking(move || installer.install(&tag))
|
||||
/// Hands off to the freshly-installed binary. Injectable so the apply path is
|
||||
/// testable without actually exiting the test process (real: [`ProcessRestarter`]).
|
||||
pub trait Restarter: Send + Sync + 'static {
|
||||
/// Restart onto the new binary. The production impl does not return.
|
||||
fn restart(&self) -> Result<()>;
|
||||
}
|
||||
|
||||
/// The production restarter: exit cleanly so the OS service manager (launchd
|
||||
/// `KeepAlive` / systemd `Restart=always`) respawns the new binary. In-flight
|
||||
/// RPC connections simply drop; clients reconnect (the nvim plugin already does).
|
||||
pub struct ProcessRestarter;
|
||||
|
||||
impl Restarter for ProcessRestarter {
|
||||
fn restart(&self) -> Result<()> {
|
||||
tracing::info!("self-update: exiting to let the service manager start the new binary");
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a detected update: install the binaries for `tag`, then restart onto
|
||||
/// them. The blocking install runs on the blocking pool so it never stalls the
|
||||
/// async runtime; the restart only happens if the install succeeded.
|
||||
pub async fn apply_update(
|
||||
installer: Arc<dyn Installer>,
|
||||
restarter: Arc<dyn Restarter>,
|
||||
tag: &str,
|
||||
) -> Result<()> {
|
||||
let owned = tag.to_string();
|
||||
tokio::task::spawn_blocking(move || installer.install(&owned))
|
||||
.await
|
||||
.context("self-update install task panicked")?
|
||||
.context("self-update install task panicked")??;
|
||||
tracing::info!(%tag, "self-update: installed; restarting into the new binary");
|
||||
restarter.restart()
|
||||
}
|
||||
|
||||
/// The background poll loop: tick on `interval`, check for a newer release, and
|
||||
|
|
@ -197,6 +222,7 @@ pub async fn apply_update(installer: Arc<dyn Installer>, tag: &str) -> Result<()
|
|||
pub async fn run_poll_loop<S: ReleaseSource>(
|
||||
source: S,
|
||||
installer: Arc<dyn Installer>,
|
||||
restarter: Arc<dyn Restarter>,
|
||||
interval: Duration,
|
||||
current: &'static str,
|
||||
) {
|
||||
|
|
@ -206,9 +232,10 @@ pub async fn run_poll_loop<S: ReleaseSource>(
|
|||
match check_release(&source, current).await {
|
||||
CheckOutcome::UpdateAvailable(tag) => {
|
||||
tracing::info!(%tag, current, "self-update: newer release available, applying");
|
||||
match apply_update(installer.clone(), &tag).await {
|
||||
Ok(()) => tracing::info!(%tag, "self-update: installed new binaries"),
|
||||
Err(e) => tracing::error!("self-update: install failed for {tag}: {e}"),
|
||||
// On success the restarter exits the process, so this only
|
||||
// returns on failure — log it and keep polling.
|
||||
if let Err(e) = apply_update(installer.clone(), restarter.clone(), &tag).await {
|
||||
tracing::error!("self-update: failed for {tag}: {e}");
|
||||
}
|
||||
}
|
||||
CheckOutcome::UpToDate => tracing::debug!(current, "self-update: up to date"),
|
||||
|
|
@ -245,22 +272,47 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_update_invokes_the_installer_with_the_tag() {
|
||||
let inst = Arc::new(FakeInstaller::default());
|
||||
apply_update(inst.clone(), "v1.0.4").await.unwrap();
|
||||
assert_eq!(*inst.installed.lock().unwrap(), vec!["v1.0.4".to_string()]);
|
||||
/// Records whether a restart was requested (instead of exiting the process).
|
||||
#[derive(Default)]
|
||||
struct FakeRestarter {
|
||||
restarted: std::sync::Mutex<bool>,
|
||||
}
|
||||
impl Restarter for FakeRestarter {
|
||||
fn restart(&self) -> Result<()> {
|
||||
*self.restarted.lock().unwrap() = true;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_update_propagates_install_failure() {
|
||||
async fn apply_update_installs_then_restarts_on_success() {
|
||||
let inst = Arc::new(FakeInstaller::default());
|
||||
let restart = Arc::new(FakeRestarter::default());
|
||||
apply_update(inst.clone(), restart.clone(), "v1.0.4")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(*inst.installed.lock().unwrap(), vec!["v1.0.4".to_string()]);
|
||||
assert!(
|
||||
*restart.restarted.lock().unwrap(),
|
||||
"should restart on success"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_update_does_not_restart_when_install_fails() {
|
||||
let inst = Arc::new(FakeInstaller {
|
||||
fail: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(apply_update(inst.clone(), "v1.0.4").await.is_err());
|
||||
// It still attempted the install for the right tag.
|
||||
let restart = Arc::new(FakeRestarter::default());
|
||||
assert!(apply_update(inst.clone(), restart.clone(), "v1.0.4")
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(*inst.installed.lock().unwrap(), vec!["v1.0.4".to_string()]);
|
||||
assert!(
|
||||
!*restart.restarted.lock().unwrap(),
|
||||
"must NOT restart after a failed install"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -133,13 +133,22 @@ impl Daemon {
|
|||
let source = selfupdate::ForgeReleaseSource::new(self.ctx.http.clone());
|
||||
let installer: std::sync::Arc<dyn selfupdate::Installer> =
|
||||
std::sync::Arc::new(selfupdate::CargoInstaller);
|
||||
let restarter: std::sync::Arc<dyn selfupdate::Restarter> =
|
||||
std::sync::Arc::new(selfupdate::ProcessRestarter);
|
||||
tracing::info!(
|
||||
interval_secs = cfg.interval.as_secs(),
|
||||
current = heph_core::VERSION,
|
||||
"self-update enabled"
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
selfupdate::run_poll_loop(source, installer, cfg.interval, heph_core::VERSION).await;
|
||||
selfupdate::run_poll_loop(
|
||||
source,
|
||||
installer,
|
||||
restarter,
|
||||
cfg.interval,
|
||||
heph_core::VERSION,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue