use std::cmp::Ordering;
use anyhow::{Context, bail};
use dirs::home_dir;
use super::compare_semver::compare_semver;
fn try_write(update: &UpdateCheck) {
if let Err(e) = update.write() {
eprintln!("warning: failed to write update cache: {e}");
}
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
pub struct UpdateCheck {
pub last_update_check: Option<chrono::DateTime<chrono::Utc>>,
pub latest_version: Option<String>,
#[serde(default)]
pub download_failures: u32,
#[serde(default)]
pub skipped_version: Option<String>,
#[serde(default)]
pub last_package_manager_spawn: Option<chrono::DateTime<chrono::Utc>>,
}
impl UpdateCheck {
fn has_stale_latest_version(&self) -> bool {
self.latest_version
.as_deref()
.map(|latest| {
!matches!(
compare_semver(env!("CARGO_PKG_VERSION"), latest),
Ordering::Less
)
})
.unwrap_or(false)
}
fn clear_latest_fields(&mut self) {
self.latest_version = None;
self.download_failures = 0;
self.last_package_manager_spawn = None;
self.last_update_check = None;
}
pub fn write(&self) -> anyhow::Result<()> {
let home = home_dir().context("Failed to get home directory")?;
let path = home.join(".railway/version.json");
let contents = serde_json::to_string_pretty(&self)?;
super::write_atomic(&path, &contents)
}
fn mutate(f: impl FnOnce(&mut Self)) {
let mut update = Self::read().unwrap_or_default();
f(&mut update);
try_write(&update);
}
pub fn persist_latest(version: Option<&str>) {
Self::mutate(|u| {
u.last_update_check = Some(chrono::Utc::now());
if u.latest_version.as_deref() != version {
u.last_package_manager_spawn = None;
}
u.latest_version = version.map(String::from);
u.download_failures = 0;
});
}
pub fn read_normalized() -> Self {
let mut update = Self::read().unwrap_or_default();
if update.has_stale_latest_version() {
update.clear_latest_fields();
try_write(&update);
}
update
}
pub fn skip_version(version: &str) {
Self::mutate(|u| {
u.skipped_version = Some(version.to_string());
u.last_package_manager_spawn = None;
u.last_update_check = None;
});
}
pub fn clear_after_update() {
Self::mutate(|u| {
u.last_update_check = Some(chrono::Utc::now());
u.latest_version = None;
u.download_failures = 0;
u.last_package_manager_spawn = None;
u.skipped_version = None;
});
}
const MAX_DOWNLOAD_FAILURES: u32 = 3;
pub fn record_download_failure() {
Self::mutate(|u| {
u.download_failures += 1;
if u.download_failures >= Self::MAX_DOWNLOAD_FAILURES {
u.latest_version = None;
u.last_update_check = None;
u.download_failures = 0;
}
});
}
pub fn record_package_manager_spawn() {
Self::mutate(|u| {
u.last_package_manager_spawn = Some(chrono::Utc::now());
});
}
pub fn should_spawn_package_manager() -> bool {
Self::read()
.map(|u| match u.last_package_manager_spawn {
Some(t) => (chrono::Utc::now() - t) >= chrono::Duration::hours(1),
None => true,
})
.unwrap_or(true)
}
pub fn read() -> anyhow::Result<Self> {
let home = home_dir().context("Failed to get home directory")?;
let path = home.join(".railway/version.json");
let contents =
std::fs::read_to_string(&path).context("Failed to read update check file")?;
serde_json::from_str::<Self>(&contents).context("Failed to parse update check file")
}
}
#[derive(serde::Deserialize)]
struct GithubApiRelease {
tag_name: String,
}
const GITHUB_API_RELEASE_URL: &str = "https://api.github.com/repos/railwayapp/cli/releases/latest";
pub async fn check_update(force: bool) -> anyhow::Result<Option<String>> {
let update = UpdateCheck::read().unwrap_or_default();
if let Some(last_update_check) = update.last_update_check {
if (chrono::Utc::now() - last_update_check) < chrono::Duration::hours(12) && !force {
return Ok(None);
}
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
let response = client
.get(GITHUB_API_RELEASE_URL)
.header("User-Agent", "railwayapp")
.send()
.await?;
let response = response.json::<GithubApiRelease>().await?;
let latest_version = response.tag_name.trim_start_matches('v');
match compare_semver(env!("CARGO_PKG_VERSION"), latest_version) {
Ordering::Less => {
let mut fresh = UpdateCheck::read().unwrap_or_default();
if fresh.skipped_version.as_deref() != Some(latest_version) {
fresh.last_update_check = Some(chrono::Utc::now());
}
if fresh.latest_version.as_deref() != Some(latest_version) {
fresh.last_package_manager_spawn = None;
}
fresh.latest_version = Some(latest_version.to_owned());
fresh.download_failures = 0;
fresh.write()?;
Ok(Some(latest_version.to_string()))
}
_ => {
UpdateCheck::persist_latest(None);
Ok(None)
}
}
}
pub fn spawn_package_manager_update(
method: super::install_method::InstallMethod,
) -> anyhow::Result<()> {
let (program, args) = method
.package_manager_command()
.context("No package manager command for this install method")?;
if which::which(program).is_err() {
bail!("Package manager '{program}' not found in PATH");
}
use fs2::FileExt;
let lock_path = super::self_update::package_update_lock_path()?;
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let lock_file =
std::fs::File::create(&lock_path).context("Failed to create package-update lock file")?;
lock_file
.try_lock_exclusive()
.map_err(|_| anyhow::anyhow!("Another update process is starting. Please try again."))?;
if crate::telemetry::is_auto_update_disabled() {
bail!("Auto-updates were disabled while waiting for lock");
}
if !UpdateCheck::should_spawn_package_manager() {
bail!("Package-manager update was spawned recently; waiting before retrying");
}
let pid_path = super::self_update::package_update_pid_path()?;
if let Some(pid) = is_background_update_running(&pid_path) {
bail!("Another update process (pid {pid}) is already running");
}
let log_path = super::self_update::auto_update_log_path()?;
let mut cmd = std::process::Command::new(program);
cmd.args(&args);
let child = super::spawn_detached(&mut cmd, &log_path)?;
let child_pid = child.id();
std::mem::forget(child);
let now = chrono::Utc::now().timestamp();
let _ = std::fs::write(&pid_path, format!("{child_pid} {now}"));
UpdateCheck::record_package_manager_spawn();
Ok(())
}
const PID_STALENESS_TTL_SECS: i64 = 600;
pub fn parse_pid_file(contents: &str) -> Option<(u32, i64)> {
let mut parts = contents.split_whitespace();
let pid = parts.next()?.parse().ok()?;
let ts = parts.next()?.parse().ok()?;
Some((pid, ts))
}
pub fn is_background_update_running(pid_path: &std::path::Path) -> Option<u32> {
let contents = std::fs::read_to_string(pid_path).ok()?;
let (pid, ts) = parse_pid_file(&contents)?;
let age_secs = chrono::Utc::now().timestamp().saturating_sub(ts);
if age_secs < PID_STALENESS_TTL_SECS && is_pid_alive(pid) {
Some(pid)
} else {
None
}
}
pub fn is_pid_alive(pid: u32) -> bool {
#[cfg(unix)]
{
use nix::sys::signal::kill;
use nix::unistd::Pid;
matches!(
kill(Pid::from_raw(pid as i32), None),
Ok(()) | Err(nix::errno::Errno::EPERM)
)
}
#[cfg(windows)]
{
use winapi::um::handleapi::CloseHandle;
use winapi::um::processthreadsapi::{GetExitCodeProcess, OpenProcess};
use winapi::um::winnt::PROCESS_QUERY_INFORMATION;
const STILL_ACTIVE: u32 = 259;
unsafe {
let handle = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid);
if handle.is_null() {
return false;
}
let mut exit_code: u32 = 0;
let ok = GetExitCodeProcess(handle, &mut exit_code as *mut u32 as *mut _) != 0;
CloseHandle(handle);
ok && exit_code == STILL_ACTIVE
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn next_version(version: &str) -> String {
let mut parts = version
.split('-')
.next()
.unwrap_or(version)
.split('.')
.map(|part| part.parse::<u8>().unwrap_or(0))
.collect::<Vec<_>>();
parts.resize(3, 0);
for idx in (0..parts.len()).rev() {
if parts[idx] < u8::MAX {
parts[idx] += 1;
for part in parts.iter_mut().skip(idx + 1) {
*part = 0;
}
return format!("{}.{}.{}", parts[0], parts[1], parts[2]);
}
}
"255.255.255-rc.1".to_string()
}
#[test]
fn stale_latest_version_is_detected_and_cleared() {
let mut update = UpdateCheck {
last_update_check: Some(chrono::Utc::now()),
latest_version: Some(env!("CARGO_PKG_VERSION").to_string()),
download_failures: 2,
skipped_version: Some("0.1.0".to_string()),
last_package_manager_spawn: Some(chrono::Utc::now()),
};
assert!(update.has_stale_latest_version());
update.clear_latest_fields();
assert!(update.latest_version.is_none());
assert_eq!(update.download_failures, 0);
assert!(update.last_package_manager_spawn.is_none());
assert!(update.last_update_check.is_none());
assert_eq!(update.skipped_version.as_deref(), Some("0.1.0"));
}
#[test]
fn newer_latest_version_is_not_stale() {
let update = UpdateCheck {
latest_version: Some(next_version(env!("CARGO_PKG_VERSION"))),
..Default::default()
};
assert!(!update.has_stale_latest_version());
}
}