mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(update): auto-upgrade bsk when daemon finds a newer version (#77)
The daemon's periodic update check now installs a newer release instead of only refreshing the hint cache: it reuses the update.rs install path (download, sha256 verify, extract, replace), skips the round while any agent session is live, and otherwise restarts itself into the new binary (spawns a detached replacement that waits for the old pid, then exits). On by default; BSK_AUTO_UPDATE=off keeps the old cache/hint-only behaviour. Windows can only stage the replacement, so it just logs that a restart is needed. The CLI hint wording reflects the switch.
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
//! falls through to `run_foreground`.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
@@ -34,6 +35,12 @@ use crate::daemon::{
|
||||
/// to indicate "you are the daemon, detach yourself and run".
|
||||
pub(crate) const DAEMONIZED_ENV: &str = "BSK_DAEMONIZED";
|
||||
|
||||
/// Internal env-var contract for daemon self-restart after an
|
||||
/// auto-update: the outgoing daemon spawns its replacement with this set
|
||||
/// to its own pid; the child waits for that pid to exit — releasing the
|
||||
/// daemon lock, IPC socket, and WS port — before taking over.
|
||||
pub(crate) const DAEMON_REPLACEMENT_WAIT_ENV: &str = "BSK_DAEMON_REPLACES_PID";
|
||||
|
||||
/// Concrete daemon configuration resolved from CLI flags / defaults.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DaemonConfig {
|
||||
@@ -108,6 +115,7 @@ pub fn run_start(args: StartArgs) -> Result<()> {
|
||||
|
||||
// Detached child mode (set by parent before spawn).
|
||||
if is_daemonized_child() {
|
||||
wait_for_replaced_daemon();
|
||||
detach_stdio()?;
|
||||
return run_foreground(cfg);
|
||||
}
|
||||
@@ -220,7 +228,12 @@ 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();
|
||||
// Fired by the update check task after a successful auto-update:
|
||||
// the replacement daemon has already been spawned, so this
|
||||
// process should shut down and let it take over.
|
||||
let restart_notify = Arc::new(tokio::sync::Notify::new());
|
||||
let update_check_task =
|
||||
spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify));
|
||||
let ws_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), cfg.ws_port);
|
||||
let ws_handle = ws::WsServer::new(Arc::clone(&state))
|
||||
.bind(ws_addr)
|
||||
@@ -367,6 +380,11 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
|
||||
};
|
||||
drop(idle_rx);
|
||||
|
||||
// Created before `select!` so a `notify_one` from the update
|
||||
// check task is never missed (Notify stores one permit).
|
||||
let restart_notified = restart_notify.notified();
|
||||
tokio::pin!(restart_notified);
|
||||
|
||||
tokio::select! {
|
||||
_ = wait_for_shutdown() => {
|
||||
info!("bsk daemon shutting down (signal)");
|
||||
@@ -376,6 +394,9 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
|
||||
info!("bsk daemon shutting down (idle)");
|
||||
}
|
||||
}
|
||||
_ = &mut restart_notified => {
|
||||
info!("bsk daemon shutting down (auto-update restart)");
|
||||
}
|
||||
}
|
||||
|
||||
let _ = ipc_shutdown_tx.send(());
|
||||
@@ -509,7 +530,21 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::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<()> {
|
||||
///
|
||||
/// When a tick finds a newer version the daemon also *installs* it
|
||||
/// (auto-update, on by default; [`crate::cli::update::AUTO_UPDATE_ENV`]
|
||||
/// `=off` disables it and keeps the check cache/hint-only). Safety
|
||||
/// gate: while any agent session is live the tick postpones the
|
||||
/// install and retries next time. Once the new binary is in place the
|
||||
/// task spawns the replacement daemon (see
|
||||
/// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process
|
||||
/// shuts down and the new version takes over; on Windows the
|
||||
/// replacement can only be staged, so it just logs that a restart is
|
||||
/// needed.
|
||||
pub(crate) fn spawn_update_check_task(
|
||||
state: Arc<DaemonState>,
|
||||
restart: Arc<tokio::sync::Notify>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
use crate::cli::update;
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -520,6 +555,17 @@ pub(crate) fn spawn_update_check_task() -> tokio::task::JoinHandle<()> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Capture our own executable path once, up front: after an
|
||||
// auto-update replaces the binary, `current_exe` on Linux starts
|
||||
// returning a " (deleted)"-suffixed path that can neither be
|
||||
// replaced again nor spawned.
|
||||
let exe_path = match std::env::current_exe() {
|
||||
Ok(exe) => Some(exe),
|
||||
Err(err) => {
|
||||
warn!(error = %err, "auto-update install disabled: cannot locate current executable");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let mut ticker = tokio::time::interval(update::UPDATE_CHECK_INTERVAL);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
@@ -544,18 +590,97 @@ pub(crate) fn spawn_update_check_task() -> tokio::task::JoinHandle<()> {
|
||||
|
||||
let result = {
|
||||
let cache_path = cache_path.clone();
|
||||
tokio::task::spawn_blocking(move || update::refresh_update_cache(&cache_path)).await
|
||||
let state = Arc::clone(&state);
|
||||
let exe_path = exe_path.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let candidate = update::refresh_update_cache(&cache_path)?;
|
||||
// The session gate is read after the fetch, as late
|
||||
// as possible before the binary gets replaced.
|
||||
let active_sessions = state.sessions.len();
|
||||
let auto_update = update::auto_update_enabled() && exe_path.is_some();
|
||||
update::auto_update_step(
|
||||
candidate.as_ref(),
|
||||
auto_update,
|
||||
active_sessions,
|
||||
|candidate| {
|
||||
let target =
|
||||
exe_path.as_deref().context("current executable unknown")?;
|
||||
update::self_install_candidate(candidate, target)
|
||||
},
|
||||
)
|
||||
})
|
||||
.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"),
|
||||
let outcome = match result {
|
||||
Ok(Ok(outcome)) => outcome,
|
||||
Ok(Err(err)) => {
|
||||
warn!(error = %err, "periodic update check failed");
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "periodic update check task panicked");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match outcome {
|
||||
update::AutoUpdateOutcome::UpToDate => {
|
||||
info!("periodic update check refreshed cache (already up to date)")
|
||||
}
|
||||
update::AutoUpdateOutcome::Disabled { latest } => info!(
|
||||
%latest,
|
||||
"periodic update check found a new version; auto-update off, CLI hint only"
|
||||
),
|
||||
update::AutoUpdateOutcome::PostponedSessions { latest, sessions } => info!(
|
||||
%latest,
|
||||
sessions,
|
||||
"auto-update postponed: agent session(s) active; will retry on the next tick"
|
||||
),
|
||||
update::AutoUpdateOutcome::Staged { latest } => warn!(
|
||||
%latest,
|
||||
"auto-update staged the new binary but cannot replace the running daemon in place; restart the daemon (or terminal) to finish the upgrade"
|
||||
),
|
||||
update::AutoUpdateOutcome::Replaced { latest } => {
|
||||
info!(
|
||||
current = env!("CARGO_PKG_VERSION"),
|
||||
%latest,
|
||||
"auto-update installed the new bsk binary; restarting daemon"
|
||||
);
|
||||
// `exe_path` is always Some here: the install only
|
||||
// runs when it was captured.
|
||||
if let Some(exe) = &exe_path {
|
||||
let args = restart_start_args(&state.config);
|
||||
match spawn_detached_at(exe, &args, Some(std::process::id())) {
|
||||
Ok(()) => {
|
||||
info!(
|
||||
pid = std::process::id(),
|
||||
"replacement daemon spawned; exiting so it can take over"
|
||||
);
|
||||
restart.notify_one();
|
||||
return;
|
||||
}
|
||||
Err(err) => warn!(
|
||||
error = %err,
|
||||
"auto-update replaced the binary but failed to spawn the replacement daemon; the next daemon start picks up the new version"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Rebuild the `StartArgs` for the replacement daemon from the running
|
||||
/// config so the respawn keeps the same port and idle timeouts.
|
||||
fn restart_start_args(cfg: &DaemonConfig) -> StartArgs {
|
||||
StartArgs {
|
||||
port: Some(cfg.ws_port),
|
||||
foreground: false,
|
||||
session_idle: Some(cfg.session_idle),
|
||||
daemon_idle: Some(cfg.daemon_idle),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_activity(activity: &Arc<Mutex<Instant>>) {
|
||||
if let Ok(mut a) = activity.lock() {
|
||||
*a = Instant::now();
|
||||
@@ -627,6 +752,24 @@ fn is_daemonized_child() -> bool {
|
||||
std::env::var(DAEMONIZED_ENV).as_deref() == Ok("1")
|
||||
}
|
||||
|
||||
/// Self-update handoff: when the outgoing daemon spawned us as its
|
||||
/// replacement ([`DAEMON_REPLACEMENT_WAIT_ENV`]), wait for its pid to
|
||||
/// exit so the daemon lock, IPC socket, and WS port are free before we
|
||||
/// try to take them over. Bounded on purpose — the lockfile is the real
|
||||
/// backstop if the predecessor somehow lingers.
|
||||
fn wait_for_replaced_daemon() {
|
||||
let pid = std::env::var(DAEMON_REPLACEMENT_WAIT_ENV)
|
||||
.ok()
|
||||
.and_then(|raw| raw.parse::<u32>().ok());
|
||||
let Some(pid) = pid else {
|
||||
return;
|
||||
};
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while lockfile::pid_alive(pid) && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn detach_stdio() -> Result<()> {
|
||||
use std::fs::OpenOptions;
|
||||
@@ -670,14 +813,27 @@ fn detach_stdio() -> Result<()> {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn spawn_detached(args: &StartArgs) -> Result<()> {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let exe = std::env::current_exe().context("current_exe")?;
|
||||
spawn_detached_at(&exe, args, None)
|
||||
}
|
||||
|
||||
/// Spawn a detached daemon child running the binary at `exe`. When
|
||||
/// `predecessor_pid` is set, the child first waits for that process to
|
||||
/// exit ([`DAEMON_REPLACEMENT_WAIT_ENV`]) — used by the auto-update
|
||||
/// self-restart, where the on-disk binary has already been replaced, so
|
||||
/// the child runs the new version.
|
||||
#[cfg(unix)]
|
||||
fn spawn_detached_at(exe: &Path, args: &StartArgs, predecessor_pid: Option<u32>) -> Result<()> {
|
||||
use std::os::unix::process::CommandExt;
|
||||
let mut cmd = std::process::Command::new(exe);
|
||||
apply_start_args(&mut cmd, args);
|
||||
cmd.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.env(DAEMONIZED_ENV, "1");
|
||||
if let Some(pid) = predecessor_pid {
|
||||
cmd.env(DAEMON_REPLACEMENT_WAIT_ENV, pid.to_string());
|
||||
}
|
||||
// Place the child into its own session before exec to fully detach.
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
@@ -692,17 +848,26 @@ fn spawn_detached(args: &StartArgs) -> Result<()> {
|
||||
|
||||
#[cfg(windows)]
|
||||
fn spawn_detached(args: &StartArgs) -> Result<()> {
|
||||
let exe = std::env::current_exe().context("current_exe")?;
|
||||
spawn_detached_at(&exe, args, None)
|
||||
}
|
||||
|
||||
/// Windows counterpart of the unix [`spawn_detached_at`]; see its docs.
|
||||
#[cfg(windows)]
|
||||
fn spawn_detached_at(exe: &Path, args: &StartArgs, predecessor_pid: Option<u32>) -> Result<()> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const DETACHED_PROCESS: u32 = 0x0000_0008;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
||||
let exe = std::env::current_exe().context("current_exe")?;
|
||||
let mut cmd = std::process::Command::new(exe);
|
||||
apply_start_args(&mut cmd, args);
|
||||
cmd.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.env(DAEMONIZED_ENV, "1")
|
||||
.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
|
||||
.env(DAEMONIZED_ENV, "1");
|
||||
if let Some(pid) = predecessor_pid {
|
||||
cmd.env(DAEMON_REPLACEMENT_WAIT_ENV, pid.to_string());
|
||||
}
|
||||
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
|
||||
let _child = cmd.spawn().context("spawn detached daemon child")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -714,6 +879,13 @@ fn spawn_detached(_args: &StartArgs) -> Result<()> {
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn spawn_detached_at(_exe: &Path, _args: &StartArgs, _predecessor_pid: Option<u32>) -> Result<()> {
|
||||
Err(anyhow::anyhow!(
|
||||
"detached daemon spawn is not supported on this platform"
|
||||
))
|
||||
}
|
||||
|
||||
fn apply_start_args(cmd: &mut std::process::Command, args: &StartArgs) {
|
||||
cmd.arg("daemon").arg("start");
|
||||
if let Some(p) = args.port {
|
||||
@@ -880,4 +1052,19 @@ mod tests {
|
||||
assert_eq!(format_duration(Duration::from_secs(5)), "5s");
|
||||
assert_eq!(format_duration(Duration::from_millis(750)), "750ms");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_start_args_preserve_the_running_config() {
|
||||
let cfg = DaemonConfig {
|
||||
ws_port: 1234,
|
||||
session_idle: Duration::from_secs(11),
|
||||
daemon_idle: Duration::from_secs(22),
|
||||
..DaemonConfig::new(0)
|
||||
};
|
||||
let args = restart_start_args(&cfg);
|
||||
assert_eq!(args.port, Some(1234));
|
||||
assert!(!args.foreground);
|
||||
assert_eq!(args.session_idle, Some(Duration::from_secs(11)));
|
||||
assert_eq!(args.daemon_idle, Some(Duration::from_secs(22)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user