From 00dad73abd665854dc611393e931982652f16376 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 29 Oct 2025 11:21:41 +0000 Subject: [PATCH 1/7] V1 --- codex-rs/Cargo.lock | 15 + codex-rs/Cargo.toml | 2 + codex-rs/auto-updater/Cargo.toml | 20 + codex-rs/auto-updater/src/brew.rs | 790 ++++++++++++++++++++++++++++ codex-rs/auto-updater/src/errors.rs | 22 + codex-rs/auto-updater/src/lib.rs | 29 + 6 files changed, 878 insertions(+) create mode 100644 codex-rs/auto-updater/Cargo.toml create mode 100644 codex-rs/auto-updater/src/brew.rs create mode 100644 codex-rs/auto-updater/src/errors.rs create mode 100644 codex-rs/auto-updater/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ff7bc42626..baa9320ba6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -919,6 +919,21 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "codex-auto-updater" +version = "0.0.0" +dependencies = [ + "async-trait", + "pretty_assertions", + "semver", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.16", + "tokio", + "which", +] + [[package]] name = "codex-backend-client" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f9d0865f32..6445b467e7 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -40,6 +40,7 @@ members = [ "utils/readiness", "utils/string", "utils/tokenizer", + "auto-updater", ] resolver = "2" @@ -168,6 +169,7 @@ sentry = "0.34.0" serde = "1" serde_json = "1" serde_with = "3.14" +semver = "1" serial_test = "3.2.0" sha1 = "0.10.6" sha2 = "0.10" diff --git a/codex-rs/auto-updater/Cargo.toml b/codex-rs/auto-updater/Cargo.toml new file mode 100644 index 0000000000..7df5de1e1d --- /dev/null +++ b/codex-rs/auto-updater/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "codex-auto-updater" +version.workspace = true +edition.workspace = true + +[dependencies] +async-trait = { workspace = true } +semver = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "process", "rt-multi-thread"] } +which = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } + +[lints] +workspace = true diff --git a/codex-rs/auto-updater/src/brew.rs b/codex-rs/auto-updater/src/brew.rs new file mode 100644 index 0000000000..fa9317697d --- /dev/null +++ b/codex-rs/auto-updater/src/brew.rs @@ -0,0 +1,790 @@ +use crate::Installer; +use crate::errors::Error; +use async_trait::async_trait; +use semver::Version; +use serde::Deserialize; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; + +const CODENAME: &str = "codex"; + +#[derive(Clone, Debug)] +pub(crate) struct BrewInstaller { + path: PathBuf, +} + +impl BrewInstaller { + pub(crate) fn detect() -> Result, Error> { + let path = match which::which("brew") { + Ok(path) => path, + Err(which::Error::CannotFindBinaryPath) => return Err(Error::BrewMissing), + Err(err) => return Err(Error::Io(err.to_string())), + }; + + let installer = Self { path }; + match installer.status() { + Ok(_) => Ok(Some(installer)), + Err(Error::Unsupported) => Ok(None), + Err(err) => Err(err), + } + } + + fn status(&self) -> Result { + if let Some(info) = self.formula_info()? { + let current_version = self.formula_current_version()?; + return Ok(InstallStatus { + method: InstallMethod::Formula, + current_version, + latest_version: info.latest_version, + }); + } + if let Some(info) = self.cask_info()? { + let current_version = self.cask_current_version()?; + return Ok(InstallStatus { + method: InstallMethod::Cask, + current_version, + latest_version: info.latest_version, + }); + } + Err(Error::Unsupported) + } + + async fn upgrade(&self, method: InstallMethod) -> Result<(), Error> { + let args: &[&str] = match method { + InstallMethod::Formula => &["upgrade", CODENAME], + InstallMethod::Cask => &["upgrade", "--cask", CODENAME], + }; + run_command_async(&self.path, args, Some(("HOMEBREW_NO_AUTO_UPDATE", "1"))).await?; + Ok(()) + } + + fn current_version(&self, method: InstallMethod) -> Result { + match method { + InstallMethod::Formula => self.formula_current_version(), + InstallMethod::Cask => self.cask_current_version(), + } + } + + fn formula_info(&self) -> Result, Error> { + let output = match run_command_sync(&self.path, &["info", "--json=v2", CODENAME]) { + Ok(output) => output, + Err(Error::Command { .. }) => return Ok(None), + Err(err) => return Err(err), + }; + let parsed: BrewFormulaInfoResponse = + serde_json::from_str(&output.stdout).map_err(|err| Error::Json(err.to_string()))?; + let formula = match parsed.formulae.into_iter().next() { + Some(value) => value, + None => return Ok(None), + }; + if formula.installed.is_empty() { + return Ok(None); + } + let latest_version = formula + .versions + .stable + .ok_or_else(|| Error::Version("missing stable formula version".into()))?; + Ok(Some(BrewFormulaInfo { latest_version })) + } + + fn cask_info(&self) -> Result, Error> { + let output = match run_command_sync(&self.path, &["info", "--cask", "--json=v2", CODENAME]) + { + Ok(output) => output, + Err(Error::Command { .. }) => return Ok(None), + Err(err) => return Err(err), + }; + let parsed: BrewCaskInfoResponse = + serde_json::from_str(&output.stdout).map_err(|err| Error::Json(err.to_string()))?; + let cask = match parsed.casks.into_iter().next() { + Some(value) => value, + None => return Ok(None), + }; + if cask.installed.is_empty() { + return Ok(None); + } + let latest_version = cask + .version + .ok_or_else(|| Error::Version("missing cask version".into()))?; + Ok(Some(BrewCaskInfo { latest_version })) + } + + fn formula_current_version(&self) -> Result { + self.parse_current_version(&["list", "--formula", "--versions", CODENAME]) + } + + fn cask_current_version(&self) -> Result { + self.parse_current_version(&["list", "--cask", "--versions", CODENAME]) + } + + fn parse_current_version(&self, args: &[&str]) -> Result { + let output = run_command_sync(&self.path, args)?; + parse_brew_list_version(&output.stdout) + } +} + +#[async_trait] +impl Installer for BrewInstaller { + fn update_available(&self) -> Result { + let status = self.status()?; + status.needs_update() + } + + async fn update(&self) -> Result { + let initial_status = run_blocking({ + let brew = self.clone(); + move || brew.status() + }) + .await?; + + if !initial_status.needs_update()? { + return Ok(initial_status.current_version); + } + + self.upgrade(initial_status.method).await?; + + run_blocking({ + let brew = self.clone(); + move || brew.current_version(initial_status.method) + }) + .await + } +} + +#[derive(Clone, Copy, Debug)] +enum InstallMethod { + Formula, + Cask, +} + +#[derive(Debug)] +struct InstallStatus { + method: InstallMethod, + current_version: String, + latest_version: String, +} + +impl InstallStatus { + fn needs_update(&self) -> Result { + compare_versions(&self.current_version, &self.latest_version) + } +} + +#[derive(Debug, Deserialize)] +struct BrewFormulaInfoResponse { + formulae: Vec, +} + +#[derive(Debug, Deserialize)] +struct BrewFormulaEntry { + installed: Vec, + versions: BrewFormulaVersions, +} + +#[derive(Debug, Deserialize)] +struct BrewFormulaVersions { + stable: Option, +} + +#[derive(Debug)] +struct BrewFormulaInfo { + latest_version: String, +} + +#[derive(Debug, Deserialize)] +struct BrewCaskInfoResponse { + casks: Vec, +} + +#[derive(Debug, Deserialize)] +struct BrewCaskEntry { + installed: Vec, + version: Option, +} + +#[derive(Debug)] +struct BrewCaskInfo { + latest_version: String, +} + +struct CommandOutput { + stdout: String, +} + +fn run_command_sync(path: &Path, args: &[&str]) -> Result { + let output = Command::new(path) + .args(args) + .output() + .map_err(|err| Error::Io(err.to_string()))?; + handle_command_output(path, args, output) +} + +async fn run_command_async( + path: &Path, + args: &[&str], + env: Option<(&str, &str)>, +) -> Result { + let mut command = tokio::process::Command::new(path); + command.args(args); + if let Some((key, value)) = env { + command.env(key, value); + } + let output = command + .output() + .await + .map_err(|err| Error::Io(err.to_string()))?; + handle_command_output(path, args, output) +} + +fn handle_command_output( + path: &Path, + args: &[&str], + output: std::process::Output, +) -> Result { + if output.status.success() { + Ok(CommandOutput { + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + }) + } else { + Err(Error::Command { + command: format_command(path, args), + status: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } +} + +fn format_command(path: &Path, args: &[&str]) -> String { + let mut display = path.display().to_string(); + for arg in args { + display.push(' '); + display.push_str(arg); + } + display +} + +fn parse_brew_list_version(stdout: &str) -> Result { + let line = stdout + .lines() + .find(|candidate| !candidate.trim().is_empty()) + .ok_or_else(|| Error::Version("missing version output".into()))?; + let mut parts = line.split_whitespace(); + let name = parts + .next() + .ok_or_else(|| Error::Version("unexpected brew list output".into()))?; + if name != CODENAME { + return Err(Error::Version( + "brew list returned unexpected formula".into(), + )); + } + let version = parts + .last() + .ok_or_else(|| Error::Version("brew list did not include version".into()))?; + Ok(version.to_string()) +} + +fn compare_versions(current: &str, latest: &str) -> Result { + match (Version::parse(current), Version::parse(latest)) { + (Ok(current_semver), Ok(latest_semver)) => Ok(latest_semver > current_semver), + (Err(_), Err(_)) | (Ok(_), Err(_)) | (Err(_), Ok(_)) => { + if let Some(result) = compare_brew_versions(current, latest) { + return Ok(result); + } + Ok(latest > current) + } + } +} + +fn compare_brew_versions(current: &str, latest: &str) -> Option { + let (current_semver, current_revision) = parse_brew_version(current)?; + let (latest_semver, latest_revision) = parse_brew_version(latest)?; + if current_semver != latest_semver { + return Some(latest_semver > current_semver); + } + Some(latest_revision > current_revision) +} + +fn parse_brew_version(version: &str) -> Option<(Version, i64)> { + let (core, revision) = version + .split_once('_') + .map_or((version, "0"), |(base, revision)| (base, revision)); + let semver = normalize_semver(core)?; + let revision_value = if revision.is_empty() { + 0 + } else { + revision.parse::().ok()? + }; + Some((semver, revision_value)) +} + +fn normalize_semver(version: &str) -> Option { + if let Ok(parsed) = Version::parse(version) { + return Some(parsed); + } + + let (without_build, build) = version + .split_once('+') + .map_or((version, None), |(core, build)| (core, Some(build))); + let (numeric, suffix) = without_build + .split_once('-') + .map_or((without_build, None), |(core, suffix)| (core, Some(suffix))); + + if numeric.is_empty() { + return None; + } + + let mut components: Vec<&str> = numeric.split('.').collect(); + if components.is_empty() || components.iter().any(|component| component.is_empty()) { + return None; + } + if components.len() > 3 { + return None; + } + while components.len() < 3 { + components.push("0"); + } + + let mut normalized = components.join("."); + if let Some(suffix) = suffix { + if suffix.is_empty() { + return None; + } + normalized.push('-'); + normalized.push_str(suffix); + } + if let Some(build) = build { + if build.is_empty() { + return None; + } + normalized.push('+'); + normalized.push_str(build); + } + + Version::parse(&normalized).ok() +} + +async fn run_blocking(func: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + T: Send + 'static, +{ + tokio::task::spawn_blocking(func) + .await + .map_err(|err| Error::Io(err.to_string()))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compare_versions_prefers_semver() -> Result<(), Error> { + pretty_assertions::assert_eq!(compare_versions("0.8.0", "0.9.0")?, true); + pretty_assertions::assert_eq!(compare_versions("0.9.0", "0.9.0")?, false); + pretty_assertions::assert_eq!(compare_versions("0.10.0", "0.9.0")?, false); + Ok(()) + } + + #[test] + fn compare_versions_falls_back_to_string_compare() -> Result<(), Error> { + pretty_assertions::assert_eq!(compare_versions("0.9.0_1", "0.9.1")?, true); + pretty_assertions::assert_eq!(compare_versions("1.0-nightly", "1.0-nightly")?, false); + Ok(()) + } + + #[test] + fn compare_versions_handles_brew_revision_suffix() -> Result<(), Error> { + pretty_assertions::assert_eq!(compare_versions("0.9.0_1", "0.10.0_1")?, true); + pretty_assertions::assert_eq!(compare_versions("0.10.0_1", "0.10.0_2")?, true); + pretty_assertions::assert_eq!(compare_versions("0.10.0_2", "0.10.0_1")?, false); + pretty_assertions::assert_eq!(compare_versions("0.10.0", "0.10.0_1")?, true); + Ok(()) + } + + #[cfg(unix)] + mod unix { + use super::*; + use crate::update; + use crate::update_available; + use serde_json::json; + use std::env; + use std::error::Error as StdError; + use std::ffi::OsStr; + use std::ffi::OsString; + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::path::Path; + use std::path::PathBuf; + use std::sync::Mutex; + use std::sync::OnceLock; + use tempfile::TempDir; + + const BREW_SCRIPT: &str = r#"#!/bin/sh +set -eu + +command="$1" +case "$command" in + info) + if [ "${2:-}" = "--cask" ]; then + cat "$BREW_CASK_INFO" + else + cat "$BREW_FORMULA_INFO" + fi + ;; + list) + if [ "${2:-}" = "--cask" ]; then + cat "$BREW_CASK_LIST" + else + cat "$BREW_FORMULA_LIST" + fi + ;; + upgrade) + if [ "${HOMEBREW_NO_AUTO_UPDATE:-}" != "1" ]; then + echo "missing HOMEBREW_NO_AUTO_UPDATE" >&2 + exit 7 + fi + if [ "${2:-}" = "--cask" ]; then + printf '%s\n' 'upgrade --cask codex' >> "$BREW_UPGRADE_LOG" + cp "$BREW_CASK_UPDATED_LIST" "$BREW_CASK_LIST" + else + printf '%s\n' 'upgrade codex' >> "$BREW_UPGRADE_LOG" + cp "$BREW_FORMULA_UPDATED_LIST" "$BREW_FORMULA_LIST" + fi + ;; + *) + echo "unsupported command: $command" >&2 + exit 8 + ;; +esac +"#; + + #[tokio::test] + async fn update_available_reports_formula_upgrade() -> Result<(), Box> { + let fake_brew = FakeBrew::formula("0.8.0", "0.9.0", "0.9.0")?; + + let available = update_available()?; + + pretty_assertions::assert_eq!(available, true); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn update_available_reports_formula_up_to_date() -> Result<(), Box> { + let fake_brew = FakeBrew::formula("0.9.0", "0.9.0", "0.9.0")?; + + let available = update_available()?; + + pretty_assertions::assert_eq!(available, false); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn update_executes_formula_upgrade() -> Result<(), Box> { + let fake_brew = FakeBrew::formula("0.8.0", "0.9.0", "0.9.0")?; + + let version = update().await?; + + pretty_assertions::assert_eq!(version, "0.9.0".to_string()); + pretty_assertions::assert_eq!( + fake_brew.upgrade_log_contents()?, + "upgrade codex\n".to_string() + ); + pretty_assertions::assert_eq!( + fake_brew.current_list_contents(InstallMethod::Formula)?, + "codex 0.9.0\n".to_string() + ); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn update_skips_formula_when_up_to_date() -> Result<(), Box> { + let fake_brew = FakeBrew::formula("0.9.0", "0.9.0", "0.9.0")?; + + let version = update().await?; + + pretty_assertions::assert_eq!(version, "0.9.0".to_string()); + pretty_assertions::assert_eq!(fake_brew.upgrade_log_contents()?, String::new()); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn update_available_reports_cask_upgrade() -> Result<(), Box> { + let fake_brew = FakeBrew::cask("0.8.0", "0.9.0", "0.9.0")?; + + let available = update_available()?; + + pretty_assertions::assert_eq!(available, true); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn update_executes_cask_upgrade() -> Result<(), Box> { + let fake_brew = FakeBrew::cask("0.8.0", "0.9.0", "0.9.0")?; + + let version = update().await?; + + pretty_assertions::assert_eq!(version, "0.9.0".to_string()); + pretty_assertions::assert_eq!( + fake_brew.upgrade_log_contents()?, + "upgrade --cask codex\n".to_string() + ); + pretty_assertions::assert_eq!( + fake_brew.current_list_contents(InstallMethod::Cask)?, + "codex 0.9.0\n".to_string() + ); + drop(fake_brew); + Ok(()) + } + + #[tokio::test] + async fn detects_cask_when_formula_entry_missing() -> Result<(), Box> { + let fake_brew = FakeBrew::cask_without_formula_entry("0.8.0", "0.9.0", "0.9.0")?; + + let available = update_available()?; + pretty_assertions::assert_eq!(available, true); + + let version = update().await?; + pretty_assertions::assert_eq!(version, "0.9.0".to_string()); + + drop(fake_brew); + Ok(()) + } + + struct FakeBrew { + _tempdir: TempDir, + _env: EnvironmentGuard, + _vars: Vec, + upgrade_log: PathBuf, + formula_list: PathBuf, + cask_list: PathBuf, + } + + impl FakeBrew { + fn formula( + current: &str, + latest: &str, + updated: &str, + ) -> Result> { + Self::new( + build_formula_info(&[current], latest), + build_cask_info(&[], latest), + format!("codex {current}\n"), + "codex 0.0.0\n".to_string(), + format!("codex {updated}\n"), + "codex 0.0.0\n".to_string(), + ) + } + + fn cask(current: &str, latest: &str, updated: &str) -> Result> { + Self::new( + build_formula_info(&[], latest), + build_cask_info(&[current], latest), + "codex 0.0.0\n".to_string(), + format!("codex {current}\n"), + "codex 0.0.0\n".to_string(), + format!("codex {updated}\n"), + ) + } + + fn cask_without_formula_entry( + current: &str, + latest: &str, + updated: &str, + ) -> Result> { + Self::new( + build_empty_formula_info(), + build_cask_info(&[current], latest), + "codex 0.0.0\n".to_string(), + format!("codex {current}\n"), + "codex 0.0.0\n".to_string(), + format!("codex {updated}\n"), + ) + } + + fn new( + formula_info: serde_json::Value, + cask_info: serde_json::Value, + formula_list: String, + cask_list: String, + formula_updated_list: String, + cask_updated_list: String, + ) -> Result> { + let tempdir = TempDir::new()?; + let brew_path = tempdir.path().join("brew"); + fs::write(&brew_path, BREW_SCRIPT)?; + let mut permissions = fs::metadata(&brew_path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&brew_path, permissions)?; + + let formula_info_string = serde_json::to_string(&formula_info)?; + let formula_info_path = tempdir.path().join("formula_info.json"); + fs::write(&formula_info_path, formula_info_string.as_bytes())?; + let cask_info_string = serde_json::to_string(&cask_info)?; + let cask_info_path = tempdir.path().join("cask_info.json"); + fs::write(&cask_info_path, cask_info_string.as_bytes())?; + + let formula_list_path = tempdir.path().join("formula_list.txt"); + fs::write(&formula_list_path, formula_list.as_bytes())?; + let cask_list_path = tempdir.path().join("cask_list.txt"); + fs::write(&cask_list_path, cask_list.as_bytes())?; + + let formula_updated_path = tempdir.path().join("formula_list_updated.txt"); + fs::write(&formula_updated_path, formula_updated_list.as_bytes())?; + let cask_updated_path = tempdir.path().join("cask_list_updated.txt"); + fs::write(&cask_updated_path, cask_updated_list.as_bytes())?; + + let upgrade_log = tempdir.path().join("upgrade.log"); + fs::write(&upgrade_log, Vec::new())?; + + let env = EnvironmentGuard::new(tempdir.path()); + let mut vars = Vec::new(); + vars.push(VarGuard::new("BREW_FORMULA_INFO", &formula_info_path)); + vars.push(VarGuard::new("BREW_CASK_INFO", &cask_info_path)); + vars.push(VarGuard::new("BREW_FORMULA_LIST", &formula_list_path)); + vars.push(VarGuard::new("BREW_CASK_LIST", &cask_list_path)); + vars.push(VarGuard::new( + "BREW_FORMULA_UPDATED_LIST", + &formula_updated_path, + )); + vars.push(VarGuard::new("BREW_CASK_UPDATED_LIST", &cask_updated_path)); + vars.push(VarGuard::new("BREW_UPGRADE_LOG", &upgrade_log)); + + Ok(Self { + _tempdir: tempdir, + _env: env, + _vars: vars, + upgrade_log, + formula_list: formula_list_path, + cask_list: cask_list_path, + }) + } + + fn upgrade_log_contents(&self) -> Result> { + Ok(fs::read_to_string(&self.upgrade_log)?) + } + + fn current_list_contents( + &self, + method: InstallMethod, + ) -> Result> { + let path = match method { + InstallMethod::Formula => &self.formula_list, + InstallMethod::Cask => &self.cask_list, + }; + Ok(fs::read_to_string(path)?) + } + } + + fn build_formula_info(installed: &[&str], latest: &str) -> serde_json::Value { + json!({ + "formulae": [{ + "installed": installed + .iter() + .map(|version| json!({"version": version})) + .collect::>(), + "versions": {"stable": latest} + }] + }) + } + + fn build_cask_info(installed: &[&str], latest: &str) -> serde_json::Value { + json!({ + "casks": [{ + "installed": installed + .iter() + .map(|version| json!({"version": version})) + .collect::>(), + "version": latest + }] + }) + } + + fn build_empty_formula_info() -> serde_json::Value { + json!({ + "formulae": [] + }) + } + + struct EnvironmentGuard { + _lock: std::sync::MutexGuard<'static, ()>, + _path_guard: PathGuard, + } + + impl EnvironmentGuard { + fn new(new_dir: &Path) -> Self { + let lock = acquire_environment_lock(); + let path_guard = PathGuard::set(new_dir); + Self { + _lock: lock, + _path_guard: path_guard, + } + } + } + + struct PathGuard { + original: Option, + } + + impl PathGuard { + fn set(new_dir: &Path) -> Self { + let original = env::var_os("PATH"); + let mut joined = OsString::new(); + joined.push(new_dir.as_os_str()); + if let Some(current) = original.as_ref() { + joined.push(OsStr::new(":")); + joined.push(current); + } + // SAFETY: environment access is guarded by acquire_environment_lock(). + unsafe { env::set_var("PATH", &joined) }; + Self { original } + } + } + + impl Drop for PathGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => unsafe { env::set_var("PATH", value) }, + None => unsafe { env::remove_var("PATH") }, + } + } + } + + struct VarGuard { + key: &'static str, + original: Option, + } + + impl VarGuard { + fn new(key: &'static str, value: &Path) -> Self { + let original = env::var_os(key); + // SAFETY: environment access is guarded by acquire_environment_lock(). + unsafe { env::set_var(key, value) }; + Self { key, original } + } + } + + impl Drop for VarGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => unsafe { env::set_var(self.key, value) }, + None => unsafe { env::remove_var(self.key) }, + } + } + } + + fn acquire_environment_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + match LOCK.get_or_init(|| Mutex::new(())).lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } + } +} diff --git a/codex-rs/auto-updater/src/errors.rs b/codex-rs/auto-updater/src/errors.rs new file mode 100644 index 0000000000..f52d172ba6 --- /dev/null +++ b/codex-rs/auto-updater/src/errors.rs @@ -0,0 +1,22 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum Error { + #[error("unsupported install method")] + Unsupported, + #[error("brew not found in PATH")] + BrewMissing, + #[error("command failed: {command}")] + Command { + command: String, + status: Option, + stdout: String, + stderr: String, + }, + #[error("json parse error: {0}")] + Json(String), + #[error("version parse error: {0}")] + Version(String), + #[error("io error: {0}")] + Io(String), +} diff --git a/codex-rs/auto-updater/src/lib.rs b/codex-rs/auto-updater/src/lib.rs new file mode 100644 index 0000000000..c73e2b5845 --- /dev/null +++ b/codex-rs/auto-updater/src/lib.rs @@ -0,0 +1,29 @@ +mod brew; +mod errors; + +use async_trait::async_trait; +pub use errors::Error; + +use crate::brew::BrewInstaller; + +#[async_trait] +pub trait Installer: Send + Sync { + fn update_available(&self) -> Result; + + async fn update(&self) -> Result; +} + +pub fn installer() -> Result, Error> { + if let Some(installer) = BrewInstaller::detect()? { + return Ok(Box::new(installer)); + } + Err(Error::Unsupported) +} + +pub fn update_available() -> Result { + installer()?.update_available() +} + +pub async fn update() -> Result { + installer()?.update().await +} From 5544ec8cc6ceac1074bfa67737b6ad7678e6158a Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 29 Oct 2025 12:31:44 +0000 Subject: [PATCH 2/7] V2 --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + codex-rs/auto-updater/src/brew.rs | 64 +++++++++++++++++++++---------- codex-rs/auto-updater/src/lib.rs | 17 +++++++- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/lib.rs | 18 +++++++++ 6 files changed, 81 insertions(+), 21 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index baa9320ba6..98370c4fc5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1460,6 +1460,7 @@ dependencies = [ "codex-ansi-escape", "codex-app-server-protocol", "codex-arg0", + "codex-auto-updater", "codex-common", "codex-core", "codex-feedback", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 6445b467e7..5c6c1b859b 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -60,6 +60,7 @@ codex-app-server = { path = "app-server" } codex-app-server-protocol = { path = "app-server-protocol" } codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } +codex-auto-updater = { path = "auto-updater" } codex-async-utils = { path = "async-utils" } codex-backend-client = { path = "backend-client" } codex-chatgpt = { path = "chatgpt" } diff --git a/codex-rs/auto-updater/src/brew.rs b/codex-rs/auto-updater/src/brew.rs index fa9317697d..bda0c6c046 100644 --- a/codex-rs/auto-updater/src/brew.rs +++ b/codex-rs/auto-updater/src/brew.rs @@ -1,4 +1,5 @@ use crate::Installer; +use crate::UpdateStatus; use crate::errors::Error; use async_trait::async_trait; use semver::Version; @@ -23,14 +24,14 @@ impl BrewInstaller { }; let installer = Self { path }; - match installer.status() { + match installer.install_status() { Ok(_) => Ok(Some(installer)), Err(Error::Unsupported) => Ok(None), Err(err) => Err(err), } } - fn status(&self) -> Result { + fn install_status(&self) -> Result { if let Some(info) = self.formula_info()? { let current_version = self.formula_current_version()?; return Ok(InstallStatus { @@ -126,27 +127,39 @@ impl BrewInstaller { #[async_trait] impl Installer for BrewInstaller { - fn update_available(&self) -> Result { - let status = self.status()?; - status.needs_update() + fn version_status(&self) -> Result { + let status = self.install_status()?; + let update_available = status.needs_update()?; + let InstallStatus { + method: _, + current_version, + latest_version, + } = status; + Ok(UpdateStatus { + current_version, + latest_version, + update_available, + }) } async fn update(&self) -> Result { let initial_status = run_blocking({ let brew = self.clone(); - move || brew.status() + move || brew.install_status() }) .await?; - if !initial_status.needs_update()? { + let needs_update = initial_status.needs_update()?; + let method = initial_status.method; + if !needs_update { return Ok(initial_status.current_version); } - self.upgrade(initial_status.method).await?; + self.upgrade(method).await?; run_blocking({ let brew = self.clone(); - move || brew.current_version(initial_status.method) + move || brew.current_version(method) }) .await } @@ -482,6 +495,19 @@ esac Ok(()) } + #[tokio::test] + async fn update_status_reports_versions() -> Result<(), Box> { + let fake_brew = FakeBrew::formula("0.8.0", "0.9.0", "0.9.0")?; + + let status = crate::update_status()?; + + pretty_assertions::assert_eq!(status.update_available, true); + pretty_assertions::assert_eq!(status.current_version, "0.8.0".to_string()); + pretty_assertions::assert_eq!(status.latest_version, "0.9.0".to_string()); + drop(fake_brew); + Ok(()) + } + #[tokio::test] async fn update_executes_formula_upgrade() -> Result<(), Box> { let fake_brew = FakeBrew::formula("0.8.0", "0.9.0", "0.9.0")?; @@ -644,17 +670,15 @@ esac fs::write(&upgrade_log, Vec::new())?; let env = EnvironmentGuard::new(tempdir.path()); - let mut vars = Vec::new(); - vars.push(VarGuard::new("BREW_FORMULA_INFO", &formula_info_path)); - vars.push(VarGuard::new("BREW_CASK_INFO", &cask_info_path)); - vars.push(VarGuard::new("BREW_FORMULA_LIST", &formula_list_path)); - vars.push(VarGuard::new("BREW_CASK_LIST", &cask_list_path)); - vars.push(VarGuard::new( - "BREW_FORMULA_UPDATED_LIST", - &formula_updated_path, - )); - vars.push(VarGuard::new("BREW_CASK_UPDATED_LIST", &cask_updated_path)); - vars.push(VarGuard::new("BREW_UPGRADE_LOG", &upgrade_log)); + let vars = vec![ + VarGuard::new("BREW_FORMULA_INFO", &formula_info_path), + VarGuard::new("BREW_CASK_INFO", &cask_info_path), + VarGuard::new("BREW_FORMULA_LIST", &formula_list_path), + VarGuard::new("BREW_CASK_LIST", &cask_list_path), + VarGuard::new("BREW_FORMULA_UPDATED_LIST", &formula_updated_path), + VarGuard::new("BREW_CASK_UPDATED_LIST", &cask_updated_path), + VarGuard::new("BREW_UPGRADE_LOG", &upgrade_log), + ]; Ok(Self { _tempdir: tempdir, diff --git a/codex-rs/auto-updater/src/lib.rs b/codex-rs/auto-updater/src/lib.rs index c73e2b5845..8ac555eb79 100644 --- a/codex-rs/auto-updater/src/lib.rs +++ b/codex-rs/auto-updater/src/lib.rs @@ -6,9 +6,20 @@ pub use errors::Error; use crate::brew::BrewInstaller; +#[derive(Debug)] +pub struct UpdateStatus { + pub current_version: String, + pub latest_version: String, + pub update_available: bool, +} + #[async_trait] pub trait Installer: Send + Sync { - fn update_available(&self) -> Result; + fn version_status(&self) -> Result; + + fn update_available(&self) -> Result { + self.version_status().map(|status| status.update_available) + } async fn update(&self) -> Result; } @@ -20,6 +31,10 @@ pub fn installer() -> Result, Error> { Err(Error::Unsupported) } +pub fn update_status() -> Result { + installer()?.version_status() +} + pub fn update_available() -> Result { installer()?.update_available() } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index f087202c22..9d711c3f25 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -33,6 +33,7 @@ codex-common = { workspace = true, features = [ "elapsed", "sandbox_summary", ] } +codex-auto-updater = { workspace = true } codex-core = { workspace = true } codex-file-search = { workspace = true } codex-login = { workspace = true } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 028bf68e87..429c5400fd 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -7,6 +7,8 @@ use additional_dirs::add_dir_warning_message; use app::App; pub use app::AppExitInfo; use codex_app_server_protocol::AuthMode; +use codex_auto_updater::Error as AutoUpdateError; +use codex_auto_updater::update_status; use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; @@ -256,6 +258,22 @@ pub async fn run_main( .try_init(); }; + match update_status() { + Ok(status) if status.update_available => { + let current = status.current_version; + let latest = status.latest_version; + tracing::error!( + current_version = current.as_str(), + latest_version = latest.as_str(), + "A newer Codex release is available. Update Codex from {current} to {latest} with `brew upgrade codex`." + ); + } + Ok(_) | Err(AutoUpdateError::Unsupported) => {} + Err(err) => { + tracing::debug!(error = ?err, "Failed to check for Codex updates"); + } + } + run_ratatui_app( cli, config, From f45cf49b3ddeed87e354ea44d97338045960c2d2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 29 Oct 2025 16:24:54 +0000 Subject: [PATCH 3/7] V3 --- codex-rs/Cargo.lock | 10 ++ codex-rs/Cargo.toml | 2 + codex-rs/auto-updater/Cargo.toml | 2 + codex-rs/auto-updater/src/brew.rs | 151 +++++++++++++++++++++------ codex-rs/auto-updater/src/lib.rs | 48 ++++++++- codex-rs/internal-storage/Cargo.toml | 11 ++ codex-rs/internal-storage/src/lib.rs | 113 ++++++++++++++++++++ codex-rs/tui/src/lib.rs | 43 +++++--- 8 files changed, 335 insertions(+), 45 deletions(-) create mode 100644 codex-rs/internal-storage/Cargo.toml create mode 100644 codex-rs/internal-storage/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 98370c4fc5..3ec2708880 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -924,6 +924,7 @@ name = "codex-auto-updater" version = "0.0.0" dependencies = [ "async-trait", + "codex-internal-storage", "pretty_assertions", "semver", "serde", @@ -931,6 +932,7 @@ dependencies = [ "tempfile", "thiserror 2.0.16", "tokio", + "tracing", "which", ] @@ -1239,6 +1241,14 @@ dependencies = [ "walkdir", ] +[[package]] +name = "codex-internal-storage" +version = "0.0.0" +dependencies = [ + "serde_json", + "thiserror 2.0.16", +] + [[package]] name = "codex-keyring-store" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5c6c1b859b..8738e43739 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -41,6 +41,7 @@ members = [ "utils/string", "utils/tokenizer", "auto-updater", + "internal-storage", ] resolver = "2" @@ -83,6 +84,7 @@ codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } +codex-internal-storage = { path = "internal-storage" } codex-utils-cache = { path = "utils/cache" } codex-utils-image = { path = "utils/image" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } diff --git a/codex-rs/auto-updater/Cargo.toml b/codex-rs/auto-updater/Cargo.toml index 7df5de1e1d..f647fddb81 100644 --- a/codex-rs/auto-updater/Cargo.toml +++ b/codex-rs/auto-updater/Cargo.toml @@ -11,6 +11,8 @@ serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["macros", "process", "rt-multi-thread"] } which = { workspace = true } +tracing = { workspace = true } +codex-internal-storage = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/auto-updater/src/brew.rs b/codex-rs/auto-updater/src/brew.rs index bda0c6c046..3f9faa6f0e 100644 --- a/codex-rs/auto-updater/src/brew.rs +++ b/codex-rs/auto-updater/src/brew.rs @@ -4,9 +4,11 @@ use crate::errors::Error; use async_trait::async_trait; use semver::Version; use serde::Deserialize; +use std::cmp::Ordering; use std::path::Path; use std::path::PathBuf; use std::process::Command; +use std::time::Instant; const CODENAME: &str = "codex"; @@ -33,18 +35,16 @@ impl BrewInstaller { fn install_status(&self) -> Result { if let Some(info) = self.formula_info()? { - let current_version = self.formula_current_version()?; return Ok(InstallStatus { method: InstallMethod::Formula, - current_version, + current_version: info.current_version, latest_version: info.latest_version, }); } if let Some(info) = self.cask_info()? { - let current_version = self.cask_current_version()?; return Ok(InstallStatus { method: InstallMethod::Cask, - current_version, + current_version: info.current_version, latest_version: info.latest_version, }); } @@ -79,14 +79,24 @@ impl BrewInstaller { Some(value) => value, None => return Ok(None), }; - if formula.installed.is_empty() { + let installed_versions: Vec = formula + .installed + .into_iter() + .filter_map(|entry| entry.version) + .collect(); + let current_version = if installed_versions.is_empty() { return Ok(None); - } + } else { + select_highest_brew_version(&installed_versions)? + }; let latest_version = formula .versions .stable .ok_or_else(|| Error::Version("missing stable formula version".into()))?; - Ok(Some(BrewFormulaInfo { latest_version })) + Ok(Some(BrewFormulaInfo { + current_version, + latest_version, + })) } fn cask_info(&self) -> Result, Error> { @@ -102,13 +112,23 @@ impl BrewInstaller { Some(value) => value, None => return Ok(None), }; - if cask.installed.is_empty() { + let installed_versions: Vec = cask + .installed + .into_iter() + .filter_map(|entry| entry.version) + .collect(); + let current_version = if installed_versions.is_empty() { return Ok(None); - } + } else { + select_highest_brew_version(&installed_versions)? + }; let latest_version = cask .version .ok_or_else(|| Error::Version("missing cask version".into()))?; - Ok(Some(BrewCaskInfo { latest_version })) + Ok(Some(BrewCaskInfo { + current_version, + latest_version, + })) } fn formula_current_version(&self) -> Result { @@ -128,18 +148,34 @@ impl BrewInstaller { #[async_trait] impl Installer for BrewInstaller { fn version_status(&self) -> Result { - let status = self.install_status()?; - let update_available = status.needs_update()?; - let InstallStatus { - method: _, - current_version, - latest_version, - } = status; - Ok(UpdateStatus { - current_version, - latest_version, - update_available, - }) + let started = Instant::now(); + let outcome = (|| { + let status = self.install_status()?; + let update_available = status.needs_update()?; + let InstallStatus { + method: _, + current_version, + latest_version, + } = status; + Ok(UpdateStatus { + current_version, + latest_version, + update_available, + }) + })(); + let elapsed = started.elapsed(); + match &outcome { + Ok(_) => tracing::info!( + elapsed_ms = elapsed.as_millis(), + "brew version status completed" + ), + Err(err) => tracing::info!( + elapsed_ms = elapsed.as_millis(), + error = %err, + "brew version status failed" + ), + } + outcome } async fn update(&self) -> Result { @@ -191,10 +227,15 @@ struct BrewFormulaInfoResponse { #[derive(Debug, Deserialize)] struct BrewFormulaEntry { - installed: Vec, + installed: Vec, versions: BrewFormulaVersions, } +#[derive(Debug, Deserialize)] +struct BrewFormulaInstalledEntry { + version: Option, +} + #[derive(Debug, Deserialize)] struct BrewFormulaVersions { stable: Option, @@ -202,6 +243,7 @@ struct BrewFormulaVersions { #[derive(Debug)] struct BrewFormulaInfo { + current_version: String, latest_version: String, } @@ -212,12 +254,18 @@ struct BrewCaskInfoResponse { #[derive(Debug, Deserialize)] struct BrewCaskEntry { - installed: Vec, + installed: Vec, + version: Option, +} + +#[derive(Debug, Deserialize)] +struct BrewCaskInstalledEntry { version: Option, } #[derive(Debug)] struct BrewCaskInfo { + current_version: String, latest_version: String, } @@ -228,6 +276,7 @@ struct CommandOutput { fn run_command_sync(path: &Path, args: &[&str]) -> Result { let output = Command::new(path) .args(args) + .env("HOMEBREW_NO_AUTO_UPDATE", "1") .output() .map_err(|err| Error::Io(err.to_string()))?; handle_command_output(path, args, output) @@ -240,6 +289,7 @@ async fn run_command_async( ) -> Result { let mut command = tokio::process::Command::new(path); command.args(args); + command.env("HOMEBREW_NO_AUTO_UPDATE", "1"); if let Some((key, value)) = env { command.env(key, value); } @@ -298,16 +348,55 @@ fn parse_brew_list_version(stdout: &str) -> Result { Ok(version.to_string()) } -fn compare_versions(current: &str, latest: &str) -> Result { - match (Version::parse(current), Version::parse(latest)) { - (Ok(current_semver), Ok(latest_semver)) => Ok(latest_semver > current_semver), - (Err(_), Err(_)) | (Ok(_), Err(_)) | (Err(_), Ok(_)) => { - if let Some(result) = compare_brew_versions(current, latest) { - return Ok(result); +fn select_highest_brew_version(versions: &[String]) -> Result { + let mut best: Option = None; + for version in versions { + let trimmed = version.trim(); + if trimmed.is_empty() { + continue; + } + let candidate = trimmed.to_string(); + match &best { + Some(current_best) => { + if version_ordering(current_best, &candidate) == Ordering::Less { + best = Some(candidate); + } } - Ok(latest > current) + None => best = Some(candidate), } } + match best { + Some(value) => Ok(value), + None => Err(Error::Version("missing installed brew version".into())), + } +} + +fn version_ordering(lhs: &str, rhs: &str) -> Ordering { + match (Version::parse(lhs), Version::parse(rhs)) { + (Ok(lhs_semver), Ok(rhs_semver)) => lhs_semver.cmp(&rhs_semver), + _ => match (parse_brew_version(lhs), parse_brew_version(rhs)) { + (Some((lhs_semver, lhs_revision)), Some((rhs_semver, rhs_revision))) => { + match lhs_semver.cmp(&rhs_semver) { + Ordering::Equal => lhs_revision.cmp(&rhs_revision), + other => other, + } + } + _ => lhs.cmp(rhs), + }, + } +} + +fn compare_versions(current: &str, latest: &str) -> Result { + Ok(true) + // match (Version::parse(current), Version::parse(latest)) { + // (Ok(current_semver), Ok(latest_semver)) => Ok(latest_semver > current_semver), + // (Err(_), Err(_)) | (Ok(_), Err(_)) | (Err(_), Ok(_)) => { + // if let Some(result) = compare_brew_versions(current, latest) { + // return Ok(result); + // } + // Ok(latest > current) + // } + // } } fn compare_brew_versions(current: &str, latest: &str) -> Option { diff --git a/codex-rs/auto-updater/src/lib.rs b/codex-rs/auto-updater/src/lib.rs index 8ac555eb79..6930d59010 100644 --- a/codex-rs/auto-updater/src/lib.rs +++ b/codex-rs/auto-updater/src/lib.rs @@ -3,10 +3,15 @@ mod errors; use async_trait::async_trait; pub use errors::Error; +use serde::Deserialize; +use serde::Serialize; +use std::path::Path; use crate::brew::BrewInstaller; -#[derive(Debug)] +const AUTO_UPDATER_STATUS_KEY: &str = "auto_updater.status"; + +#[derive(Debug, Serialize, Deserialize)] pub struct UpdateStatus { pub current_version: String, pub latest_version: String, @@ -31,10 +36,14 @@ pub fn installer() -> Result, Error> { Err(Error::Unsupported) } -pub fn update_status() -> Result { +fn compute_update_status() -> Result { installer()?.version_status() } +pub fn update_status() -> Result { + compute_update_status() +} + pub fn update_available() -> Result { installer()?.update_available() } @@ -42,3 +51,38 @@ pub fn update_available() -> Result { pub async fn update() -> Result { installer()?.update().await } + +pub fn initialize_storage(codex_home: &Path) -> Result<(), Error> { + codex_internal_storage::initialize(codex_home.to_path_buf()); + Ok(()) +} + +pub fn read_cached_status() -> Result, Error> { + match codex_internal_storage::read(AUTO_UPDATER_STATUS_KEY) { + Ok(Some(value)) => { + let status = + serde_json::from_str(&value).map_err(|err| Error::Json(err.to_string()))?; + Ok(Some(status)) + } + Ok(None) => Ok(None), + Err(err) => Err(map_storage_error(err)), + } +} + +pub async fn refresh_status() -> Result { + let status = compute_update_status()?; + let serialized = serde_json::to_string(&status).map_err(|err| Error::Json(err.to_string()))?; + codex_internal_storage::write(AUTO_UPDATER_STATUS_KEY, &serialized) + .map_err(map_storage_error)?; + Ok(status) +} + +fn map_storage_error(err: codex_internal_storage::InternalStorageError) -> Error { + match err { + codex_internal_storage::InternalStorageError::Io(err) => Error::Io(err.to_string()), + codex_internal_storage::InternalStorageError::Json(err) => Error::Json(err.to_string()), + codex_internal_storage::InternalStorageError::Uninitialized => { + Error::Io("internal storage not initialized".into()) + } + } +} diff --git a/codex-rs/internal-storage/Cargo.toml b/codex-rs/internal-storage/Cargo.toml new file mode 100644 index 0000000000..cd419326c8 --- /dev/null +++ b/codex-rs/internal-storage/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "codex-internal-storage" +version.workspace = true +edition.workspace = true + +[dependencies] +serde_json = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/codex-rs/internal-storage/src/lib.rs b/codex-rs/internal-storage/src/lib.rs new file mode 100644 index 0000000000..6a2efbaca5 --- /dev/null +++ b/codex-rs/internal-storage/src/lib.rs @@ -0,0 +1,113 @@ +use serde_json::Map as JsonMap; +use serde_json::Value; +use std::fs; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Mutex; +use std::sync::OnceLock; +use thiserror::Error; + +const INTERNAL_STORAGE_FILENAME: &str = "internal_storage.json"; + +#[derive(Debug, Error)] +pub enum InternalStorageError { + #[error("{0}")] + Io(#[from] io::Error), + #[error("{0}")] + Json(#[from] serde_json::Error), + #[error("internal storage has not been initialized")] + Uninitialized, +} + +#[derive(Debug)] +struct Storage { + path: PathBuf, + lock: Mutex<()>, +} + +impl Storage { + fn new(path: PathBuf) -> Self { + Self { + path, + lock: Mutex::new(()), + } + } + + fn read_map(&self) -> Result, InternalStorageError> { + match fs::read_to_string(&self.path) { + Ok(contents) => { + let value: Value = serde_json::from_str(&contents)?; + match value { + Value::Object(map) => Ok(map), + Value::Null => Ok(JsonMap::new()), + _ => Ok(JsonMap::new()), + } + } + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(JsonMap::new()), + Err(err) => Err(err.into()), + } + } + + fn write_map(&self, map: &JsonMap) -> Result<(), InternalStorageError> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent)?; + } + let payload = serde_json::to_string_pretty(&Value::Object(map.clone()))?; + fs::write(&self.path, payload)?; + Ok(()) + } + + fn read(&self, key: &str) -> Result, InternalStorageError> { + let _guard = self.lock.lock().expect("internal storage lock poisoned"); + let map = self.read_map()?; + Ok(map.get(key).map(value_to_string)) + } + + fn write(&self, key: &str, value: &str) -> Result<(), InternalStorageError> { + let _guard = self.lock.lock().expect("internal storage lock poisoned"); + let mut map = self.read_map()?; + map.insert(key.to_string(), Value::String(value.to_string())); + self.write_map(&map) + } +} + +static STORAGE: OnceLock = OnceLock::new(); + +pub fn initialize(codex_home: PathBuf) { + let path = build_storage_path(&codex_home); + let storage = Storage::new(path.clone()); + match STORAGE.get() { + Some(existing) if existing.path != path => {} + Some(_) => {} + None => { + let _ = STORAGE.set(storage); + } + } +} + +pub fn read(key: &str) -> Result, InternalStorageError> { + storage()?.read(key) +} + +pub fn write(key: &str, value: &str) -> Result<(), InternalStorageError> { + storage()?.write(key, value) +} + +fn storage() -> Result<&'static Storage, InternalStorageError> { + STORAGE.get().ok_or(InternalStorageError::Uninitialized) +} + +fn build_storage_path(codex_home: &Path) -> PathBuf { + codex_home.join(INTERNAL_STORAGE_FILENAME) +} + +fn value_to_string(value: &Value) -> String { + match value { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), + _ => value.to_string(), + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 429c5400fd..32ca564694 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -8,7 +8,6 @@ use app::App; pub use app::AppExitInfo; use codex_app_server_protocol::AuthMode; use codex_auto_updater::Error as AutoUpdateError; -use codex_auto_updater::update_status; use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; @@ -258,19 +257,39 @@ pub async fn run_main( .try_init(); }; - match update_status() { - Ok(status) if status.update_available => { - let current = status.current_version; - let latest = status.latest_version; - tracing::error!( - current_version = current.as_str(), - latest_version = latest.as_str(), - "A newer Codex release is available. Update Codex from {current} to {latest} with `brew upgrade codex`." - ); + match codex_auto_updater::initialize_storage(&config.codex_home) { + Ok(_) => { + let t1 = std::time::Instant::now(); + match codex_auto_updater::read_cached_status() { + Ok(Some(status)) if status.update_available => { + let t2 = std::time::Instant::now(); + tracing::warn!("Diff: {:?}", t2 - t1); + let current = status.current_version; + let latest = status.latest_version; + tracing::error!( + current_version = current.as_str(), + latest_version = latest.as_str(), + "A newer Codex release is available. Update Codex from {current} to {latest} with `brew upgrade codex`." + ); + } + Ok(_) | Err(AutoUpdateError::Unsupported) => {} + Err(err) => { + tracing::error!(error = ?err, "Failed to read cached Codex update status"); + } + } + + tokio::spawn(async move { + if let Err(err) = codex_auto_updater::refresh_status().await { + tracing::error!(error = ?err, "Failed to refresh Codex update status"); + } + error!("Prop done"); + }); } - Ok(_) | Err(AutoUpdateError::Unsupported) => {} Err(err) => { - tracing::debug!(error = ?err, "Failed to check for Codex updates"); + tracing::error!( + error = ?err, + "Failed to initialize internal storage for Codex updates" + ); } } From ba19ee3b44a7abf3bf858689093d5d8f86acad36 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 29 Oct 2025 17:10:10 +0000 Subject: [PATCH 4/7] V4 --- codex-rs/tui/src/app.rs | 7 ++++ codex-rs/tui/src/history_cell.rs | 13 +++++--- codex-rs/tui/src/lib.rs | 55 ++++++++++++++++++++++---------- 3 files changed, 55 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8fc18a1d9b..e6f126c245 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -92,6 +92,7 @@ impl App { initial_images: Vec, resume_selection: ResumeSelection, feedback: codex_feedback::CodexFeedback, + initial_events: Vec, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); @@ -170,11 +171,17 @@ impl App { pending_update_action: None, }; + for event in initial_events { + app.handle_event(tui, event).await?; + } + + // TODO(jif) clean this #[cfg(not(debug_assertions))] if let Some(latest_version) = upgrade_version { app.handle_event( tui, AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( + crate::version::CODEX_CLI_VERSION, latest_version, crate::updates::get_update_action(), ))), diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 0cbaf12037..29d05c2c5a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -16,7 +16,6 @@ use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::updates::UpdateAction; -use crate::version::CODEX_CLI_VERSION; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; @@ -265,15 +264,21 @@ impl HistoryCell for PlainHistoryCell { #[cfg_attr(debug_assertions, allow(dead_code))] #[derive(Debug)] pub(crate) struct UpdateAvailableHistoryCell { + current_version: String, latest_version: String, update_action: Option, } #[cfg_attr(debug_assertions, allow(dead_code))] impl UpdateAvailableHistoryCell { - pub(crate) fn new(latest_version: String, update_action: Option) -> Self { + pub(crate) fn new( + current_version: impl Into, + latest_version: impl Into, + update_action: Option, + ) -> Self { Self { - latest_version, + current_version: current_version.into(), + latest_version: latest_version.into(), update_action, } } @@ -298,7 +303,7 @@ impl HistoryCell for UpdateAvailableHistoryCell { padded_emoji("✨").bold().cyan(), "Update available!".bold().cyan(), " ", - format!("{CODEX_CLI_VERSION} -> {}", self.latest_version).bold(), + format!("{} -> {}", self.current_version, self.latest_version).bold(), ], update_instruction, "", diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 32ca564694..9846479430 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -8,6 +8,7 @@ use app::App; pub use app::AppExitInfo; use codex_app_server_protocol::AuthMode; use codex_auto_updater::Error as AutoUpdateError; +use codex_auto_updater::UpdateStatus; use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; @@ -80,6 +81,8 @@ mod wrapping; #[cfg(test)] pub mod test_backend; +use crate::app_event::AppEvent; +use crate::history_cell::UpdateAvailableHistoryCell; use crate::onboarding::TrustDirectorySelection; use crate::onboarding::WSL_INSTRUCTIONS; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; @@ -257,36 +260,30 @@ pub async fn run_main( .try_init(); }; + let mut cached_update_status: Option = None; + match codex_auto_updater::initialize_storage(&config.codex_home) { Ok(_) => { - let t1 = std::time::Instant::now(); match codex_auto_updater::read_cached_status() { - Ok(Some(status)) if status.update_available => { - let t2 = std::time::Instant::now(); - tracing::warn!("Diff: {:?}", t2 - t1); - let current = status.current_version; - let latest = status.latest_version; - tracing::error!( - current_version = current.as_str(), - latest_version = latest.as_str(), - "A newer Codex release is available. Update Codex from {current} to {latest} with `brew upgrade codex`." - ); + Ok(Some(status)) => { + if status.update_available { + cached_update_status = Some(status); + } } - Ok(_) | Err(AutoUpdateError::Unsupported) => {} + Ok(None) | Err(AutoUpdateError::Unsupported) => {} Err(err) => { - tracing::error!(error = ?err, "Failed to read cached Codex update status"); + error!(error = ?err, "Failed to read cached Codex update status"); } } tokio::spawn(async move { if let Err(err) = codex_auto_updater::refresh_status().await { - tracing::error!(error = ?err, "Failed to refresh Codex update status"); + error!(error = ?err, "Failed to refresh Codex update status"); } - error!("Prop done"); }); } Err(err) => { - tracing::error!( + error!( error = ?err, "Failed to initialize internal storage for Codex updates" ); @@ -300,6 +297,7 @@ pub async fn run_main( cli_kv_overrides, active_profile, feedback, + cached_update_status, ) .await .map_err(|err| std::io::Error::other(err.to_string())) @@ -312,9 +310,33 @@ async fn run_ratatui_app( cli_kv_overrides: Vec<(String, toml::Value)>, active_profile: Option, feedback: codex_feedback::CodexFeedback, + cached_update_status: Option, ) -> color_eyre::Result { color_eyre::install()?; + let mut initial_events = Vec::new(); + if let Some(status) = cached_update_status { + if status.update_available { + let update_action = { + #[cfg(not(debug_assertions))] + { + crate::updates::get_update_action() + } + #[cfg(debug_assertions)] + { + None + } + }; + initial_events.push(AppEvent::InsertHistoryCell(Box::new( + UpdateAvailableHistoryCell::new( + status.current_version, + status.latest_version, + update_action, + ), + ))); + } + } + // Forward panic reports through tracing so they appear in the UI status // line, but do not swallow the default/color-eyre panic handler. // Chain to the previous hook so users still get a rich panic report @@ -484,6 +506,7 @@ async fn run_ratatui_app( images, resume_selection, feedback, + initial_events, ) .await; From 27b1991588e223495f677aa43dd9a6a7bce6bc2f Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 29 Oct 2025 20:05:20 +0000 Subject: [PATCH 5/7] brew update --- codex-rs/auto-updater/src/brew.rs | 75 +++++++++++++++++++++++-------- codex-rs/tui/src/lib.rs | 5 +-- 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/codex-rs/auto-updater/src/brew.rs b/codex-rs/auto-updater/src/brew.rs index 3f9faa6f0e..16de2674e8 100644 --- a/codex-rs/auto-updater/src/brew.rs +++ b/codex-rs/auto-updater/src/brew.rs @@ -33,6 +33,11 @@ impl BrewInstaller { } } + fn update_repository(&self) -> Result<(), Error> { + run_command_sync_with_env(&self.path, &["update"], &[])?; + Ok(()) + } + fn install_status(&self) -> Result { if let Some(info) = self.formula_info()? { return Ok(InstallStatus { @@ -56,7 +61,7 @@ impl BrewInstaller { InstallMethod::Formula => &["upgrade", CODENAME], InstallMethod::Cask => &["upgrade", "--cask", CODENAME], }; - run_command_async(&self.path, args, Some(("HOMEBREW_NO_AUTO_UPDATE", "1"))).await?; + run_command_async(&self.path, args, &[("HOMEBREW_NO_AUTO_UPDATE", "1")]).await?; Ok(()) } @@ -150,6 +155,7 @@ impl Installer for BrewInstaller { fn version_status(&self) -> Result { let started = Instant::now(); let outcome = (|| { + self.update_repository()?; let status = self.install_status()?; let update_available = status.needs_update()?; let InstallStatus { @@ -274,23 +280,31 @@ struct CommandOutput { } fn run_command_sync(path: &Path, args: &[&str]) -> Result { - let output = Command::new(path) - .args(args) - .env("HOMEBREW_NO_AUTO_UPDATE", "1") - .output() - .map_err(|err| Error::Io(err.to_string()))?; + run_command_sync_with_env(path, args, &[("HOMEBREW_NO_AUTO_UPDATE", "1")]) +} + +fn run_command_sync_with_env( + path: &Path, + args: &[&str], + env: &[(&str, &str)], +) -> Result { + let mut command = Command::new(path); + command.args(args); + for (key, value) in env { + command.env(key, value); + } + let output = command.output().map_err(|err| Error::Io(err.to_string()))?; handle_command_output(path, args, output) } async fn run_command_async( path: &Path, args: &[&str], - env: Option<(&str, &str)>, + env: &[(&str, &str)], ) -> Result { let mut command = tokio::process::Command::new(path); command.args(args); - command.env("HOMEBREW_NO_AUTO_UPDATE", "1"); - if let Some((key, value)) = env { + for (key, value) in env { command.env(key, value); } let output = command @@ -387,16 +401,15 @@ fn version_ordering(lhs: &str, rhs: &str) -> Ordering { } fn compare_versions(current: &str, latest: &str) -> Result { - Ok(true) - // match (Version::parse(current), Version::parse(latest)) { - // (Ok(current_semver), Ok(latest_semver)) => Ok(latest_semver > current_semver), - // (Err(_), Err(_)) | (Ok(_), Err(_)) | (Err(_), Ok(_)) => { - // if let Some(result) = compare_brew_versions(current, latest) { - // return Ok(result); - // } - // Ok(latest > current) - // } - // } + match (Version::parse(current), Version::parse(latest)) { + (Ok(current_semver), Ok(latest_semver)) => Ok(latest_semver > current_semver), + (Err(_), Err(_)) | (Ok(_), Err(_)) | (Err(_), Ok(_)) => { + if let Some(result) = compare_brew_versions(current, latest) { + return Ok(result); + } + Ok(latest > current) + } + } } fn compare_brew_versions(current: &str, latest: &str) -> Option { @@ -542,6 +555,9 @@ case "$command" in cat "$BREW_FORMULA_LIST" fi ;; + update) + printf '%s\n' 'update' >> "$BREW_UPDATE_LOG" + ;; upgrade) if [ "${HOMEBREW_NO_AUTO_UPDATE:-}" != "1" ]; then echo "missing HOMEBREW_NO_AUTO_UPDATE" >&2 @@ -597,6 +613,18 @@ esac Ok(()) } + #[tokio::test] + async fn version_status_runs_brew_update() -> Result<(), Box> { + let fake_brew = FakeBrew::formula("0.8.0", "0.9.0", "0.9.0")?; + + let status = crate::update_status()?; + + pretty_assertions::assert_eq!(status.latest_version, "0.9.0".to_string()); + pretty_assertions::assert_eq!(fake_brew.update_log_contents()?, "update\n".to_string()); + drop(fake_brew); + Ok(()) + } + #[tokio::test] async fn update_executes_formula_upgrade() -> Result<(), Box> { let fake_brew = FakeBrew::formula("0.8.0", "0.9.0", "0.9.0")?; @@ -676,6 +704,7 @@ esac _tempdir: TempDir, _env: EnvironmentGuard, _vars: Vec, + update_log: PathBuf, upgrade_log: PathBuf, formula_list: PathBuf, cask_list: PathBuf, @@ -755,6 +784,8 @@ esac let cask_updated_path = tempdir.path().join("cask_list_updated.txt"); fs::write(&cask_updated_path, cask_updated_list.as_bytes())?; + let update_log = tempdir.path().join("update.log"); + fs::write(&update_log, Vec::new())?; let upgrade_log = tempdir.path().join("upgrade.log"); fs::write(&upgrade_log, Vec::new())?; @@ -766,6 +797,7 @@ esac VarGuard::new("BREW_CASK_LIST", &cask_list_path), VarGuard::new("BREW_FORMULA_UPDATED_LIST", &formula_updated_path), VarGuard::new("BREW_CASK_UPDATED_LIST", &cask_updated_path), + VarGuard::new("BREW_UPDATE_LOG", &update_log), VarGuard::new("BREW_UPGRADE_LOG", &upgrade_log), ]; @@ -773,12 +805,17 @@ esac _tempdir: tempdir, _env: env, _vars: vars, + update_log, upgrade_log, formula_list: formula_list_path, cask_list: cask_list_path, }) } + fn update_log_contents(&self) -> Result> { + Ok(fs::read_to_string(&self.update_log)?) + } + fn upgrade_log_contents(&self) -> Result> { Ok(fs::read_to_string(&self.upgrade_log)?) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9846479430..20fa614f88 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -315,8 +315,8 @@ async fn run_ratatui_app( color_eyre::install()?; let mut initial_events = Vec::new(); - if let Some(status) = cached_update_status { - if status.update_available { + if let Some(status) = cached_update_status + && status.update_available { let update_action = { #[cfg(not(debug_assertions))] { @@ -335,7 +335,6 @@ async fn run_ratatui_app( ), ))); } - } // Forward panic reports through tracing so they appear in the UI status // line, but do not swallow the default/color-eyre panic handler. From e7de8118ce486335faee3064e732185e393053a9 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 29 Oct 2025 20:56:28 +0000 Subject: [PATCH 6/7] Drop legacy way --- codex-rs/tui/src/app.rs | 20 -------------------- codex-rs/tui/src/lib.rs | 25 ++++++------------------- 2 files changed, 6 insertions(+), 39 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index e6f126c245..df04b7da06 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -40,9 +40,6 @@ use std::time::Duration; use tokio::select; use tokio::sync::mpsc::unbounded_channel; -#[cfg(not(debug_assertions))] -use crate::history_cell::UpdateAvailableHistoryCell; - #[derive(Debug, Clone)] pub struct AppExitInfo { pub token_usage: TokenUsage, @@ -149,9 +146,6 @@ impl App { }; let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); - #[cfg(not(debug_assertions))] - let upgrade_version = crate::updates::get_upgrade_version(&config); - let mut app = Self { server: conversation_manager, app_event_tx, @@ -175,20 +169,6 @@ impl App { app.handle_event(tui, event).await?; } - // TODO(jif) clean this - #[cfg(not(debug_assertions))] - if let Some(latest_version) = upgrade_version { - app.handle_event( - tui, - AppEvent::InsertHistoryCell(Box::new(UpdateAvailableHistoryCell::new( - crate::version::CODEX_CLI_VERSION, - latest_version, - crate::updates::get_update_action(), - ))), - ) - .await?; - } - let tui_events = tui.event_stream(); tokio::pin!(tui_events); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 20fa614f88..a0a2683bb4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -316,25 +316,12 @@ async fn run_ratatui_app( let mut initial_events = Vec::new(); if let Some(status) = cached_update_status - && status.update_available { - let update_action = { - #[cfg(not(debug_assertions))] - { - crate::updates::get_update_action() - } - #[cfg(debug_assertions)] - { - None - } - }; - initial_events.push(AppEvent::InsertHistoryCell(Box::new( - UpdateAvailableHistoryCell::new( - status.current_version, - status.latest_version, - update_action, - ), - ))); - } + && status.update_available + { + initial_events.push(AppEvent::InsertHistoryCell(Box::new( + UpdateAvailableHistoryCell::new(status.current_version, status.latest_version, None), + ))); + } // Forward panic reports through tracing so they appear in the UI status // line, but do not swallow the default/color-eyre panic handler. From 69a9979b8a9f87cec578d60a7ae486d5733fe8b1 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 29 Oct 2025 21:10:31 +0000 Subject: [PATCH 7/7] tokio locks --- codex-rs/Cargo.lock | 1 + codex-rs/internal-storage/Cargo.toml | 1 + codex-rs/internal-storage/src/lib.rs | 37 ++++++++++++++++++++++------ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3ec2708880..da19f30103 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1247,6 +1247,7 @@ version = "0.0.0" dependencies = [ "serde_json", "thiserror 2.0.16", + "tokio", ] [[package]] diff --git a/codex-rs/internal-storage/Cargo.toml b/codex-rs/internal-storage/Cargo.toml index cd419326c8..ccc30337b9 100644 --- a/codex-rs/internal-storage/Cargo.toml +++ b/codex-rs/internal-storage/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [dependencies] serde_json = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt", "sync"] } [lints] workspace = true diff --git a/codex-rs/internal-storage/src/lib.rs b/codex-rs/internal-storage/src/lib.rs index 6a2efbaca5..5504626368 100644 --- a/codex-rs/internal-storage/src/lib.rs +++ b/codex-rs/internal-storage/src/lib.rs @@ -4,9 +4,11 @@ use std::fs; use std::io; use std::path::Path; use std::path::PathBuf; -use std::sync::Mutex; use std::sync::OnceLock; use thiserror::Error; +use tokio::runtime::Builder as RuntimeBuilder; +use tokio::runtime::Handle as RuntimeHandle; +use tokio::sync::Mutex; const INTERNAL_STORAGE_FILENAME: &str = "internal_storage.json"; @@ -58,14 +60,14 @@ impl Storage { Ok(()) } - fn read(&self, key: &str) -> Result, InternalStorageError> { - let _guard = self.lock.lock().expect("internal storage lock poisoned"); + async fn read_async(&self, key: &str) -> Result, InternalStorageError> { + let _guard = self.lock.lock().await; let map = self.read_map()?; Ok(map.get(key).map(value_to_string)) } - fn write(&self, key: &str, value: &str) -> Result<(), InternalStorageError> { - let _guard = self.lock.lock().expect("internal storage lock poisoned"); + async fn write_async(&self, key: &str, value: &str) -> Result<(), InternalStorageError> { + let _guard = self.lock.lock().await; let mut map = self.read_map()?; map.insert(key.to_string(), Value::String(value.to_string())); self.write_map(&map) @@ -87,11 +89,19 @@ pub fn initialize(codex_home: PathBuf) { } pub fn read(key: &str) -> Result, InternalStorageError> { - storage()?.read(key) + let fut = storage()?.read_async(key); + match block_on(fut) { + Ok(res) => res, + Err(err) => Err(err.into()), + } } pub fn write(key: &str, value: &str) -> Result<(), InternalStorageError> { - storage()?.write(key, value) + let fut = storage()?.write_async(key, value); + match block_on(fut) { + Ok(res) => res, + Err(err) => Err(err.into()), + } } fn storage() -> Result<&'static Storage, InternalStorageError> { @@ -111,3 +121,16 @@ fn value_to_string(value: &Value) -> String { _ => value.to_string(), } } + +fn block_on(fut: F) -> Result +where + F: std::future::Future, +{ + match RuntimeHandle::try_current() { + Ok(handle) => Ok(handle.block_on(fut)), + Err(_) => { + let rt = RuntimeBuilder::new_current_thread().enable_all().build()?; + Ok(rt.block_on(fut)) + } + } +}