From f281a9ae1f2a6bf4f4374537e9004a0f251baa68 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Thu, 10 Sep 2026 22:32:34 +0530 Subject: [PATCH 01/24] refactor(core): rename variable and launch specification types --- README.md | 4 +-- src/addons/installer/engine.rs | 32 +++++++++++------------ src/addons/installer/mod.rs | 6 ++--- src/addons/mod.rs | 2 +- src/bottle/edit.rs | 10 +++---- src/bottle/mod.rs | 4 +-- src/bottle/software.rs | 20 ++++++-------- src/bottle/state.rs | 22 ++++++++-------- src/lib.rs | 4 +-- src/library.rs | 4 +-- src/utils/{environment.rs => env_vars.rs} | 12 ++++----- src/utils/mod.rs | 2 +- src/wrapper/mod.rs | 14 +++++----- 13 files changed, 66 insertions(+), 70 deletions(-) rename src/utils/{environment.rs => env_vars.rs} (76%) diff --git a/README.md b/README.md index 2a70f26..07bbc06 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ futures-lite = "2" Open the library, inspect the current bottles, and stop its download service: ```rust -use bottles_core::{Bottles, Config, Program, SearchSource}; +use bottles_core::{Bottles, Config, ProgramSpec, SearchSource}; use futures_lite::StreamExt; #[tokio::main] @@ -65,7 +65,7 @@ async fn main() -> Result<(), bottles_core::error::Error> { } if let Some(bottle) = bottles.bottles().list().into_iter().next() { - let program = Program::new("Example", "C:/Games/example.exe")?; + let program = ProgramSpec::new("Example", "C:/Games/example.exe")?; let mut edit = bottle.edit(); edit.add_program(program.clone()); edit.commit().await?; diff --git a/src/addons/installer/engine.rs b/src/addons/installer/engine.rs index 3f30ce1..facd5f8 100644 --- a/src/addons/installer/engine.rs +++ b/src/addons/installer/engine.rs @@ -12,7 +12,7 @@ use crate::{ addons::InstallerError, error::{Error, Result, ResultExt}, runner::{Command, Spawnable, shutdown_prefix}, - utils::{archive, environment::Environment, exists}, + utils::{archive, env_vars::EnvVars, exists}, winebridge::WineBridgeClient, }; @@ -40,7 +40,7 @@ pub(crate) async fn execute( prefix, runner, winebridge, - environment, + env_vars, } = inputs; let result = async { check_cancellation(cancellation)?; @@ -52,7 +52,7 @@ pub(crate) async fn execute( prefix, runner, winebridge, - environment: &mut *environment, + env_vars: &mut *env_vars, }, resource, step, @@ -91,7 +91,7 @@ pub(crate) async fn uninstall( prefix, runner, winebridge, - environment, + env_vars, } = inputs; let result = async { @@ -104,7 +104,7 @@ pub(crate) async fn uninstall( prefix, runner, winebridge, - environment: &mut *environment, + env_vars: &mut *env_vars, }, step, restore_files, @@ -130,10 +130,10 @@ pub(crate) async fn uninstall( /// so its [`InstallStep::SetEnvironment`] steps would otherwise be absent from /// the bottle's in-memory state. Replaying is idempotent when the recipe did run; /// later entries with the same name overwrite earlier ones. -pub(crate) fn replay_environment(environment: &mut Environment, resources: &[Artifact]) { +pub(crate) fn replay_env_vars(env_vars: &mut EnvVars, resources: &[Artifact]) { for step in resources.iter().flat_map(|resource| &resource.steps) { if let InstallStep::SetEnvironment { name, value } = step { - environment.insert(name.clone(), value.clone()); + env_vars.insert(name.clone(), value.clone()); } } } @@ -148,7 +148,7 @@ async fn execute_step( prefix, runner, winebridge, - environment, + env_vars, } = inputs; match step { InstallStep::Copy { @@ -170,7 +170,7 @@ async fn execute_step( for argument in arguments { command = command.arg(argument); } - for (name, value) in environment.iter() { + for (name, value) in env_vars.iter() { command = command.env(name, value); } let status = @@ -183,7 +183,7 @@ async fn execute_step( for dll in dlls { check_cancellation(cancellation)?; let mut command = Command::new("regsvr32").arg("/s").arg(prefix.join(dll)); - for (name, value) in environment.iter() { + for (name, value) in env_vars.iter() { command = command.env(name, value); } let status = @@ -200,7 +200,7 @@ async fn execute_step( value, } => { let command = - WineBridgeClient::command(runner, prefix, winebridge).envs(environment.iter()); + WineBridgeClient::command(runner, prefix, winebridge).envs(env_vars.iter()); let bridge = WineBridgeClient::connect_or_spawn(prefix, command).await?; check_cancellation(cancellation)?; bridge @@ -209,7 +209,7 @@ async fn execute_step( } InstallStep::SetDllOverrides { dlls, mode } => { let command = - WineBridgeClient::command(runner, prefix, winebridge).envs(environment.iter()); + WineBridgeClient::command(runner, prefix, winebridge).envs(env_vars.iter()); let bridge = WineBridgeClient::connect_or_spawn(prefix, command).await?; for dll in dlls { check_cancellation(cancellation)?; @@ -217,7 +217,7 @@ async fn execute_step( } } InstallStep::SetEnvironment { name, value } => { - environment.insert(name.clone(), value.clone()); + env_vars.insert(name.clone(), value.clone()); shutdown_bridge(prefix).await?; } } @@ -235,7 +235,7 @@ async fn uninstall_step( prefix, runner, winebridge, - environment, + env_vars, } = inputs; match step { InstallStep::Copy { destination, .. } if restore_files => { @@ -245,12 +245,12 @@ async fn uninstall_step( } InstallStep::Copy { .. } => {} InstallStep::SetEnvironment { name, .. } => { - environment.remove(name); + env_vars.remove(name); shutdown_bridge(prefix).await.log_warn(); } InstallStep::SetDllOverrides { dlls, .. } => { let command = - WineBridgeClient::command(runner, prefix, winebridge).envs(environment.iter()); + WineBridgeClient::command(runner, prefix, winebridge).envs(env_vars.iter()); let bridge = match WineBridgeClient::connect_or_spawn(prefix, command).await { Ok(bridge) => bridge, Err(error) => { diff --git a/src/addons/installer/mod.rs b/src/addons/installer/mod.rs index 0a8f3a9..434349b 100644 --- a/src/addons/installer/mod.rs +++ b/src/addons/installer/mod.rs @@ -46,12 +46,12 @@ use crate::{ Directories, proto::{DllOverrideMode, RegistryHive, registry_value::Value as RegistryValue}, runner::Runner, - utils::environment::Environment, + utils::env_vars::EnvVars, }; use super::{Addon, Component, deserialize_non_empty_string}; -pub(crate) use engine::{execute, replay_environment, uninstall}; +pub(crate) use engine::{execute, replay_env_vars, uninstall}; pub(crate) use recipes::steps as recipe_steps; /// One local resource and the installation steps applied to it. @@ -154,7 +154,7 @@ pub(crate) struct InstallInputs<'a> { /// The WineBridge executable selected by the bottle. pub(crate) winebridge: &'a Path, /// The environment updated by `SetEnvironment` steps and passed to processes. - pub(crate) environment: &'a mut Environment, + pub(crate) env_vars: &'a mut EnvVars, } impl Addon { diff --git a/src/addons/mod.rs b/src/addons/mod.rs index 3381f8b..42878c9 100644 --- a/src/addons/mod.rs +++ b/src/addons/mod.rs @@ -30,7 +30,7 @@ pub use catalog::CatalogEntry; pub(crate) use catalog::Checksum; pub use error::{AddonError, CatalogError, InstallerError}; pub use index::IndexEntry; -pub(crate) use installer::{Artifact, InstallInputs, execute, replay_environment, uninstall}; +pub(crate) use installer::{Artifact, InstallInputs, execute, replay_env_vars, uninstall}; pub use manager::Addons; /// Rejects empty or whitespace-only input without trimming accepted values. diff --git a/src/bottle/edit.rs b/src/bottle/edit.rs index 2fcb0a3..d118df5 100644 --- a/src/bottle/edit.rs +++ b/src/bottle/edit.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use super::{ error::BottleError, - state::{Bottle, Program}, + state::{Bottle, ProgramSpec}, }; use crate::{ error::Result, @@ -29,7 +29,7 @@ enum Change { Rename(String), SetEnv(String, String), UnsetEnv(String), - AddProgram(Program), + AddProgram(ProgramSpec), RemoveProgram(Uuid), SetGamescope(GamescopeConfig), SetMangoHud(MangoHudConfig), @@ -77,7 +77,7 @@ impl BottleEdit { } /// Registers a program. - pub fn add_program(&mut self, program: Program) -> &mut Self { + pub fn add_program(&mut self, program: ProgramSpec) -> &mut Self { self.changes.push(Change::AddProgram(program)); self } @@ -134,13 +134,13 @@ impl BottleEdit { if value.contains('\0') { return Err(BottleError::InvalidEnvironmentValue(key).into()); } - state.environment.insert(key, value); + state.env_vars.insert(key, value); } Change::UnsetEnv(key) => { if key.is_empty() || key.contains('=') || key.contains('\0') { return Err(BottleError::InvalidEnvironmentName(key).into()); } - state.environment.remove(&key); + state.env_vars.remove(&key); } Change::AddProgram(program) => { state.programs.insert(program.id(), program); diff --git a/src/bottle/mod.rs b/src/bottle/mod.rs index 3205419..4a69921 100644 --- a/src/bottle/mod.rs +++ b/src/bottle/mod.rs @@ -6,7 +6,7 @@ //! snapshots that do not change when the bottle is edited or deleted. //! Configuration changes are queued with [`Bottle::edit`] and become visible //! only after [`BottleEdit::commit`] persists them. -//! [`Program`] construction validates launch definitions before +//! [`ProgramSpec`] construction validates launch definitions before //! [`BottleEdit::add_program`] persists them. //! //! Bottle directories and their `bottle.toml` files are library-managed. @@ -45,4 +45,4 @@ pub use error::BottleError; #[cfg(feature = "fvs")] pub use fvs_rs::{Commit as Snapshot, CommitSummary as SnapshotSummary}; pub use manager::BottleManager; -pub use state::{Bottle, BottleState, Program, Storage}; +pub use state::{Bottle, BottleState, ProgramSpec, Storage}; diff --git a/src/bottle/software.rs b/src/bottle/software.rs index d1c68e0..ad7be1d 100644 --- a/src/bottle/software.rs +++ b/src/bottle/software.rs @@ -8,7 +8,7 @@ use uuid::Uuid; use crate::{ Context, Operation, Progress, Stage, addons::{ - Addon, Artifact, InstallInputs, Requirement, Slot, execute, replay_environment, uninstall, + Addon, Artifact, InstallInputs, Requirement, Slot, execute, replay_env_vars, uninstall, }, error::{Error, Result}, proto::{DllOverride, DllOverrideMode, Process}, @@ -126,7 +126,7 @@ impl Bottle { /// Returns a snapshot of Windows processes visible in the bottle. /// - /// This includes processes not launched from a registered [`crate::Program`]. + /// This includes processes not launched from a registered [`crate::ProgramSpec`]. /// WineBridge starts if necessary, and no ordering guarantee is made. /// /// # Errors @@ -301,9 +301,7 @@ impl Bottle { let bottle_path = cx.directories().bottle(state.id); let context = cx.clone(); let BottleState { - storage, - environment, - .. + storage, env_vars, .. } = state; storage .uninstall( @@ -315,7 +313,7 @@ impl Bottle { prefix, runner: runner.as_ref(), winebridge: &winebridge, - environment, + env_vars, }, &resources, restore_files, @@ -423,9 +421,7 @@ impl Bottle { let bottle_path = cx.directories().bottle(state.id); let context = cx.clone(); let BottleState { - storage, - environment, - .. + storage, env_vars, .. } = state; let step_progress = progress.clone(); storage @@ -439,7 +435,7 @@ impl Bottle { prefix, runner: runner.as_ref(), winebridge: &winebridge, - environment, + env_vars, }, &resources, cancellation, @@ -456,7 +452,7 @@ impl Bottle { }, ) .await?; - replay_environment(environment, &resources); + replay_env_vars(env_vars, &resources); Ok(()) } @@ -531,7 +527,7 @@ impl Bottle { &prefix, state.winebridge().path(self.0.cx.directories()), ) - .envs(state.environment.iter()), + .envs(state.env_vars.iter()), ); storage.prepare(&bottle_path, &cx).await?; work(WineBridgeClient::connect_or_spawn(&prefix, command).await?).await diff --git a/src/bottle/state.rs b/src/bottle/state.rs index 920ae7f..61ea0b8 100644 --- a/src/bottle/state.rs +++ b/src/bottle/state.rs @@ -22,7 +22,7 @@ use crate::{ addons::{Addon, Addons, Component, Dependency, Requirement, Slot}, error::{Error, Result}, prefix::Prefix, - utils::environment::Environment, + utils::env_vars::EnvVars, wrapper::Wrappers, }; @@ -40,14 +40,14 @@ pub struct BottleState { pub(crate) name: String, pub(crate) storage: Prefix, #[serde(default)] - pub(crate) programs: HashMap, + pub(crate) programs: HashMap, /// Runtime and prefix components pinned to exact releases. pub(crate) components: HashMap>, /// Installed dependency releases. pub(crate) dependencies: Vec>, - #[serde(default, skip_serializing_if = "Environment::is_empty")] - pub(crate) environment: Environment, + #[serde(default, skip_serializing_if = "EnvVars::is_empty")] + pub(crate) env_vars: EnvVars, #[serde(flatten)] pub(crate) wrappers: Wrappers, @@ -172,8 +172,8 @@ impl BottleState { /// Changes do not affect an already-running WineBridge. Call /// [`Bottle::stop`] before the next bridge-backed operation to apply them /// immediately. - pub fn environment(&self) -> &Environment { - &self.environment + pub fn env_vars(&self) -> &EnvVars { + &self.env_vars } /// Returns the wrapper configuration applied when WineBridge is started. @@ -186,12 +186,12 @@ impl BottleState { } /// Iterates over registered programs in unspecified order. - pub fn programs(&self) -> impl Iterator { + pub fn programs(&self) -> impl Iterator { self.programs.values() } /// Returns the registered program with identity `id`. - pub fn program(&self, id: Uuid) -> Option<&Program> { + pub fn program(&self, id: Uuid) -> Option<&ProgramSpec> { self.programs.get(&id) } @@ -261,7 +261,7 @@ impl Bottle { storage, programs: HashMap::new(), wrappers: Wrappers::default(), - environment: Environment::default(), + env_vars: EnvVars::default(), }; let bottle = Self::from_state(state, context, addons)?; bottle.save().await?; @@ -410,7 +410,7 @@ impl Bottle { /// A persisted, immutable Windows launch definition registered with a bottle. #[derive(Debug, Clone, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] -pub struct Program { +pub struct ProgramSpec { id: Uuid, name: String, executable: String, @@ -429,7 +429,7 @@ pub struct Program { new_console: bool, } -impl Program { +impl ProgramSpec { /// Creates a program with a new UUID and default launch options. pub fn new(name: impl Into, executable: impl Into) -> Result { let name = name.into(); diff --git a/src/lib.rs b/src/lib.rs index 0d8efa0..5614d0f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,7 +19,7 @@ pub use addons::{ }; pub use bottle::{ Bottle, BottleEdit, BottleError, BottleManager, BottleState, DllOverride, DllOverrideMode, - GamescopeConfig, GamescopeFilter, GamescopeScaler, MangoHudConfig, Process, Program, + GamescopeConfig, GamescopeFilter, GamescopeScaler, MangoHudConfig, Process, ProgramSpec, RegistryHive, Storage, Wrappers, }; #[cfg(feature = "fvs")] @@ -33,7 +33,7 @@ pub use profiles::{ StorefrontAccount, StorefrontProvider, }; pub use utils::directories::Directories; -pub use utils::environment::Environment; +pub use utils::env_vars::EnvVars; pub(crate) use next_proto::winebridge as proto; pub(crate) use utils::context::Context; diff --git a/src/library.rs b/src/library.rs index 12f8501..4ba5a29 100644 --- a/src/library.rs +++ b/src/library.rs @@ -10,7 +10,7 @@ use futures_util::{ use uuid::Uuid; use crate::{ - Bottle, BottleManager, PluginId, PluginKind, Plugins, Profiles, Program, + Bottle, BottleManager, PluginId, PluginKind, Plugins, Profiles, ProgramSpec, bottle::error::BottleError, credentials, error::Result, }; @@ -194,7 +194,7 @@ pub struct LibraryItem { impl LibraryItem { /// Returns the current launch definition. - pub fn program(&self) -> Result { + pub fn program(&self) -> Result { self.bottle .state()? .program(self.program_id) diff --git a/src/utils/environment.rs b/src/utils/env_vars.rs similarity index 76% rename from src/utils/environment.rs rename to src/utils/env_vars.rs index 2e21979..2fa19ff 100644 --- a/src/utils/environment.rs +++ b/src/utils/env_vars.rs @@ -4,9 +4,9 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(transparent)] -pub struct Environment(HashMap); +pub struct EnvVars(HashMap); -impl Environment { +impl EnvVars { pub(crate) fn insert(&mut self, name: T, value: T) -> Option { self.0.insert(name, value) } @@ -19,12 +19,12 @@ impl Environment { self.0.remove(name) } - pub(crate) fn extend(&mut self, environment: impl IntoIterator) { - self.0.extend(environment); + pub(crate) fn extend(&mut self, env_vars: impl IntoIterator) { + self.0.extend(env_vars); } } -impl Environment { +impl EnvVars { pub fn get(&self, name: &str) -> Option<&str> { self.0.get(name).map(String::as_str) } @@ -40,7 +40,7 @@ impl Environment { } } -impl IntoIterator for Environment { +impl IntoIterator for EnvVars { type Item = (T, T); type IntoIter = std::collections::hash_map::IntoIter; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index e5828e5..d73ccb3 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -2,7 +2,7 @@ pub(crate) mod archive; pub(crate) mod checksum; pub(crate) mod context; pub(crate) mod directories; -pub(crate) mod environment; +pub(crate) mod env_vars; #[cfg(feature = "fvs")] use std::path::PathBuf; diff --git a/src/wrapper/mod.rs b/src/wrapper/mod.rs index 9cf73e5..feede5d 100644 --- a/src/wrapper/mod.rs +++ b/src/wrapper/mod.rs @@ -5,7 +5,7 @@ use async_process::{Child, Command as AsyncCommand}; use serde::{Deserialize, Serialize}; use std::ffi::{OsStr, OsString}; -use crate::{runner::RunnerCommand, utils::environment::Environment}; +use crate::{runner::RunnerCommand, utils::env_vars::EnvVars}; use self::{ gamescope::{Gamescope, GamescopeConfig}, @@ -58,7 +58,7 @@ pub(crate) trait Spawnable: Into + Sized { let command = self.into(); AsyncCommand::new(command.executable) .args(command.args) - .envs(command.envs) + .envs(command.env_vars) .spawn() } } @@ -69,7 +69,7 @@ impl Spawnable for Wrapped {} pub(crate) struct Command { executable: OsString, args: Vec, - envs: Environment, + env_vars: EnvVars, } impl Wrapper for Command {} @@ -79,7 +79,7 @@ impl Command { Self { executable: executable.as_ref().to_os_string(), args: Vec::new(), - envs: Environment::default(), + env_vars: EnvVars::default(), } } @@ -95,7 +95,7 @@ impl Command { } pub(crate) fn env(mut self, key: impl AsRef, value: impl AsRef) -> Self { - self.envs + self.env_vars .insert(key.as_ref().to_os_string(), value.as_ref().to_os_string()); self } @@ -104,7 +104,7 @@ impl Command { mut self, envs: impl IntoIterator, ) -> Self { - self.envs.extend( + self.env_vars.extend( envs.into_iter() .map(|(key, value)| (key.as_ref().to_os_string(), value.as_ref().to_os_string())), ); @@ -114,7 +114,7 @@ impl Command { fn append(mut self, inner: Command) -> Command { self.args.push(inner.executable); self.args.extend(inner.args); - self.envs.extend(inner.envs); + self.env_vars.extend(inner.env_vars); self } } From 9f5e579abf77e420d93d2175b7794f415f3f4bab Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Thu, 10 Sep 2026 22:40:25 +0530 Subject: [PATCH 02/24] refactor(core): move prefix storage under environment --- src/bottle/error.rs | 32 ----- src/bottle/manager.rs | 8 +- src/bottle/mod.rs | 2 +- src/bottle/snapshot.rs | 2 +- src/bottle/state.rs | 19 +-- src/bottle/tests.rs | 4 +- src/environment/mod.rs | 5 + src/{ => environment}/prefix/mod.rs | 139 ++++++++++++-------- src/environment/prefix/standard.rs | 30 +++++ src/{ => environment}/prefix/virgo/cache.rs | 17 ++- src/{ => environment}/prefix/virgo/mod.rs | 75 +++++++---- src/error.rs | 2 +- src/lib.rs | 5 +- src/prefix/standard.rs | 30 ----- 14 files changed, 190 insertions(+), 180 deletions(-) create mode 100644 src/environment/mod.rs rename src/{ => environment}/prefix/mod.rs (78%) create mode 100644 src/environment/prefix/standard.rs rename src/{ => environment}/prefix/virgo/cache.rs (95%) rename src/{ => environment}/prefix/virgo/mod.rs (78%) delete mode 100644 src/prefix/standard.rs diff --git a/src/bottle/error.rs b/src/bottle/error.rs index df2f789..84b1ffa 100644 --- a/src/bottle/error.rs +++ b/src/bottle/error.rs @@ -1,8 +1,5 @@ //! Bottle-specific errors exposed through the crate's top-level error type. -#[cfg(feature = "fvs")] -use std::path::PathBuf; - use thiserror::Error; use uuid::Uuid; @@ -67,32 +64,3 @@ pub enum BottleError { #[error("component {component} must occupy slot {required:?}")] InvalidComponentSlot { component: Uuid, required: Slot }, } - -/// Virgo-specific failures carried by [`crate::error::Error::Virgo`]. -#[cfg(feature = "fvs")] -#[derive(Debug, Error)] -pub enum VirgoError { - /// A required FVS commit is missing from a repository. - #[error("FVS repository {repository} has no commit {state}")] - MissingCommit { - /// Repository whose history was searched. - repository: PathBuf, - /// Requested full or abbreviated state ID. - state: String, - }, - /// An existing Virgo base repository has no commits to use as a layer. - #[error("Virgo base exists but has no commits")] - EmptyBase, - /// Virgo cannot initialize a base over an existing nonempty directory. - #[error("refusing to initialize non-empty Virgo base at {0}")] - DirtyBase(PathBuf), - /// Virgo cannot mount a prefix over a nonempty mountpoint. - #[error("mountpoint is not empty: {0}")] - DirtyMountpoint(PathBuf), - /// A cached layer required to construct the prefix is missing. - #[error("cached Virgo layer was not found: {0}")] - CachedLayerNotFound(PathBuf), - /// Registry data could not be converted while building a Virgo layer. - #[error("failed to process Virgo registry data: {0}")] - Registry(String), -} diff --git a/src/bottle/manager.rs b/src/bottle/manager.rs index 1b142c2..d53597e 100644 --- a/src/bottle/manager.rs +++ b/src/bottle/manager.rs @@ -17,17 +17,17 @@ use tokio_stream::wrappers::WatchStream; use uuid::Uuid; #[cfg(feature = "fvs")] -use crate::prefix::FVS_BLOCK_SIZE; +use crate::environment::prefix::FVS_BLOCK_SIZE; use crate::{ - Context, Operation, Progress, Stage, + Context, Operation, Progress, Stage, Storage, addons::{Addon, Addons, Requirement, Slot}, + environment::prefix::Prefix, error::{Error, Result}, - prefix::Prefix, }; use super::{ error::BottleError, - state::{Bottle, BottleState, Storage}, + state::{Bottle, BottleState}, }; /// The shared membership registry behind [`BottleManager`] clones. diff --git a/src/bottle/mod.rs b/src/bottle/mod.rs index 4a69921..c6f88f9 100644 --- a/src/bottle/mod.rs +++ b/src/bottle/mod.rs @@ -45,4 +45,4 @@ pub use error::BottleError; #[cfg(feature = "fvs")] pub use fvs_rs::{Commit as Snapshot, CommitSummary as SnapshotSummary}; pub use manager::BottleManager; -pub use state::{Bottle, BottleState, ProgramSpec, Storage}; +pub use state::{Bottle, BottleState, ProgramSpec}; diff --git a/src/bottle/snapshot.rs b/src/bottle/snapshot.rs index 3fe5ef2..e771c51 100644 --- a/src/bottle/snapshot.rs +++ b/src/bottle/snapshot.rs @@ -6,8 +6,8 @@ use fvs_rs::{Repository, RestoreResponse}; use crate::{ Operation, Progress, Stage, Transfer, + environment::prefix::{AUTO_CHECKPOINT_MESSAGE, FVS_BLOCK_SIZE, finish_commit, finish_restore}, error::{Error, Result}, - prefix::{AUTO_CHECKPOINT_MESSAGE, FVS_BLOCK_SIZE, finish_commit, finish_restore}, }; use super::{Bottle, Snapshot, SnapshotSummary, error::BottleError, state::BottleState}; diff --git a/src/bottle/state.rs b/src/bottle/state.rs index 61ea0b8..281a4ae 100644 --- a/src/bottle/state.rs +++ b/src/bottle/state.rs @@ -18,10 +18,10 @@ use uuid::Uuid; use super::{edit::BottleEdit, error::BottleError}; use crate::{ - Context, + Context, Storage, addons::{Addon, Addons, Component, Dependency, Requirement, Slot}, + environment::prefix::Prefix, error::{Error, Result}, - prefix::Prefix, utils::env_vars::EnvVars, wrapper::Wrappers, }; @@ -508,18 +508,3 @@ impl ProgramSpec { self.new_console } } - -/// The prefix-storage strategy persisted in [`BottleState`]. -#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] -pub enum Storage { - /// Stores a conventional mutable prefix in the bottle directory. - /// - /// With the default `fvs` feature, FVS also provides snapshots and addon - /// mutation checkpoints. - Standard, - /// Stores the prefix as composable FVS layers. - /// - /// Virgo is experimental and requires the configured FVS service. - #[cfg(feature = "fvs")] - Virgo, -} diff --git a/src/bottle/tests.rs b/src/bottle/tests.rs index 41cb83b..2613ca0 100644 --- a/src/bottle/tests.rs +++ b/src/bottle/tests.rs @@ -8,9 +8,9 @@ use tokio_util::sync::CancellationToken; use super::state::BottleInner; use crate::{ - Context, Directories, + Context, Directories, Storage, addons::{AddonError, Addons, CatalogError, Requirement, Slot}, - bottle::{Bottle, BottleManager, Storage, error::BottleError}, + bottle::{Bottle, BottleManager, error::BottleError}, error::Error, }; fn test_directories() -> Directories { diff --git a/src/environment/mod.rs b/src/environment/mod.rs new file mode 100644 index 0000000..3f8e26c --- /dev/null +++ b/src/environment/mod.rs @@ -0,0 +1,5 @@ +//! Shared execution environment implementation. + +pub(crate) mod prefix; + +pub use prefix::Storage; diff --git a/src/prefix/mod.rs b/src/environment/prefix/mod.rs similarity index 78% rename from src/prefix/mod.rs rename to src/environment/prefix/mod.rs index 5bb6941..7f08e1c 100644 --- a/src/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -1,8 +1,8 @@ //! Prefix storage backends and checkpointed addon mutation. //! -//! [`Prefix`] is persisted as part of each bottle's state. Standard storage +//! [`Prefix`] is persisted as part of its owner's state. Standard storage //! mutates a conventional prefix directly; Virgo stores an ordered FVS layer -//! stack with a per-bottle writable upper directory. With the default `fvs` +//! stack with a private writable upper directory. With the default `fvs` //! feature, addon installation and removal use an FVS rollback checkpoint. mod standard; @@ -11,6 +11,9 @@ mod virgo; use std::{future::Future, path::Path}; +#[cfg(feature = "fvs")] +pub use virgo::VirgoError; + use serde::{Deserialize, Serialize}; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -25,7 +28,7 @@ use { }, }; -use crate::{Context, Progress, bottle::Storage, error::Result, runner::Runner}; +use crate::{Context, Progress, error::Result, runner::Runner}; /// Identifies rollback checkpoints that must not appear as user snapshots. /// @@ -36,17 +39,31 @@ pub(crate) const AUTO_CHECKPOINT_MESSAGE: &str = "bottles-next:auto-checkpoint"; #[cfg(feature = "fvs")] pub(crate) const FVS_BLOCK_SIZE: u32 = 1024 * 1024; -/// Backend-specific state persisted in [`crate::bottle::BottleState`]. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -#[serde(tag = "kind")] -pub(crate) enum Prefix { +/// Selects conventional mutable storage or FVS composition. +#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +pub enum Storage { + /// Stores a conventional mutable prefix in the owner directory. + /// + /// With the default `fvs` feature, FVS also provides snapshots and addon + /// mutation checkpoints. Standard, + /// Stores the prefix as composable FVS layers. + /// + /// Virgo is experimental and requires the configured FVS service. #[cfg(feature = "fvs")] - Virgo { - /// Mount order: shared base, runner adapter, then installed addon layers. - #[serde(default)] - layers: Vec, - }, + Virgo, +} + +/// Persisted storage selection and resolved immutable layer references. +/// +/// This record owns no processes, mounts, or connections. +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] +pub(crate) struct Prefix { + kind: Storage, + /// Mount order: shared base, runner adapter, then installed addon layers. + #[cfg(feature = "fvs")] + #[serde(default, skip_serializing_if = "Vec::is_empty")] + layers: Vec, } #[cfg(feature = "fvs")] @@ -64,7 +81,7 @@ impl From<&FvsProgress> for Transfer { impl Prefix { pub(crate) async fn create( storage: Storage, - bottle_path: &Path, + root: &Path, runner: &dyn Runner, runner_key: &str, context: &Context, @@ -73,39 +90,40 @@ impl Prefix { let _ = (runner_key, context); match storage { Storage::Standard => { - standard::create(bottle_path, runner).await?; - Ok(Self::Standard) + standard::create(&root.join("prefix"), runner).await?; + Ok(Self { + kind: storage, + #[cfg(feature = "fvs")] + layers: Vec::new(), + }) } #[cfg(feature = "fvs")] - Storage::Virgo => Ok(Self::Virgo { - layers: virgo::create(bottle_path, runner, runner_key, context).await?, + Storage::Virgo => Ok(Self { + kind: storage, + layers: virgo::create(root, runner, runner_key, context).await?, }), } } pub(crate) fn kind(&self) -> Storage { - match self { - Self::Standard => Storage::Standard, - #[cfg(feature = "fvs")] - Self::Virgo { .. } => Storage::Virgo, - } + self.kind } - pub(crate) async fn prepare(&self, bottle_path: &Path, context: &Context) -> Result<()> { - let _ = (bottle_path, context); - match self { - Self::Standard => Ok(()), + pub(crate) async fn prepare(&self, root: &Path, context: &Context) -> Result<()> { + let _ = (root, context); + match self.kind { + Storage::Standard => Ok(()), #[cfg(feature = "fvs")] - Self::Virgo { layers } => virgo::prepare(bottle_path, layers, context).await, + Storage::Virgo => virgo::prepare(root, &self.layers, context).await, } } - pub(crate) async fn stop(&self, bottle_path: &Path, context: &Context) -> Result<()> { - let _ = (bottle_path, context); - match self { - Self::Standard => Ok(()), + pub(crate) async fn stop(&self, root: &Path, context: &Context) -> Result<()> { + let _ = (root, context); + match self.kind { + Storage::Standard => Ok(()), #[cfg(feature = "fvs")] - Self::Virgo { .. } => virgo::stop(bottle_path, context).await, + Storage::Virgo => virgo::stop(root, context).await, } } @@ -116,23 +134,23 @@ impl Prefix { installed: &[Uuid], context: &Context, ) -> Result<()> { - match self { - Self::Standard => { + match self.kind { + Storage::Standard => { let _ = (runner, runner_key, installed, context); Ok(()) } #[cfg(feature = "fvs")] - Self::Virgo { layers } => { + Storage::Virgo => { // Resolve the complete replacement before changing persisted state. A // missing cached addon therefore leaves the old layer stack intact. - virgo::rebuild(layers, runner, runner_key, installed, context).await + virgo::rebuild(&mut self.layers, runner, runner_key, installed, context).await } } } pub(crate) async fn install( &mut self, - bottle_path: &Path, + root: &Path, item_id: Uuid, replaced_id: Option, execute: F, @@ -146,21 +164,28 @@ impl Prefix { { let _ = (item_id, replaced_id); let work = async { - match self { - Self::Standard => standard::install(bottle_path, execute).await, + match self.kind { + Storage::Standard => standard::install(&root.join("prefix"), execute).await, #[cfg(feature = "fvs")] - Self::Virgo { layers } => { - virgo::install(bottle_path, layers, item_id, replaced_id, execute, context) - .await + Storage::Virgo => { + virgo::install( + root, + &mut self.layers, + item_id, + replaced_id, + execute, + context, + ) + .await } } }; - transact(bottle_path, context, work, cancellation, on_progress).await + transact(root, context, work, cancellation, on_progress).await } pub(crate) async fn uninstall( &mut self, - bottle_path: &Path, + root: &Path, item_id: Uuid, execute: F, context: &Context, @@ -173,15 +198,15 @@ impl Prefix { { let _ = item_id; let work = async { - match self { - Self::Standard => standard::uninstall(bottle_path, execute).await, + match self.kind { + Storage::Standard => standard::uninstall(&root.join("prefix"), execute).await, #[cfg(feature = "fvs")] - Self::Virgo { layers } => { - virgo::uninstall(bottle_path, layers, item_id, execute, context).await + Storage::Virgo => { + virgo::uninstall(root, &mut self.layers, item_id, execute, context).await } } }; - transact(bottle_path, context, work, cancellation, on_progress).await + transact(root, context, work, cancellation, on_progress).await } } @@ -194,7 +219,7 @@ impl Prefix { /// not drive the restore path. #[cfg(feature = "fvs")] async fn transact( - bottle_path: &Path, + root: &Path, context: &Context, work: F, cancellation: &CancellationToken, @@ -205,7 +230,7 @@ where P: FnMut(Progress), { let repository = Repository { - repository_path: bottle_path.display().to_string(), + repository_path: root.display().to_string(), block_size: FVS_BLOCK_SIZE, }; let stream = context @@ -262,7 +287,7 @@ where /// Runs a prefix mutation directly when FVS rollback support is not compiled in. #[cfg(not(feature = "fvs"))] async fn transact( - _bottle_path: &Path, + _root: &Path, _context: &Context, work: F, _cancellation: &CancellationToken, @@ -407,21 +432,21 @@ mod fvs_tests { let socket = directories.runtime_dir().join("fvs2d.sock"); let context = crate::Context::for_test(directories.clone(), Some(executable.into())).unwrap(); - let bottle_path = directories.bottle(Uuid::new_v4()); - std::fs::create_dir_all(&bottle_path).unwrap(); + let owner_path = directories.data_dir().join("owner"); + std::fs::create_dir_all(&owner_path).unwrap(); context .fvs() .await .unwrap() - .new_repository(&bottle_path, FVS_BLOCK_SIZE) + .new_repository(&owner_path, FVS_BLOCK_SIZE) .await .unwrap(); - let file = bottle_path.join("value"); + let file = owner_path.join("value"); async_fs::write(&file, "before").await.unwrap(); let changed = file.clone(); let result = transact( - &bottle_path, + &owner_path, &context, async move { async_fs::write(changed, "after").await?; diff --git a/src/environment/prefix/standard.rs b/src/environment/prefix/standard.rs new file mode 100644 index 0000000..d567606 --- /dev/null +++ b/src/environment/prefix/standard.rs @@ -0,0 +1,30 @@ +//! Direct mutable prefix storage. +//! +//! Recipes operate on `/prefix`. Uninstallation asks the recipe to +//! restore overwritten files because, unlike Virgo, this backend has no lower +//! layer to reveal. Transaction rollback is provided by the parent module. + +use std::{ops::AsyncFnOnce, path::Path}; + +use crate::{ + error::Result, + runner::{Runner, initialize_and_shutdown_prefix}, +}; + +pub(super) async fn create(prefix: &Path, runner: &dyn Runner) -> Result<()> { + initialize_and_shutdown_prefix(runner, prefix).await +} + +pub(super) async fn install(prefix: &Path, execute: F) -> Result<()> +where + F: for<'a> AsyncFnOnce(&'a Path) -> Result<()>, +{ + execute(prefix).await +} + +pub(super) async fn uninstall(prefix: &Path, execute: F) -> Result<()> +where + F: for<'a> AsyncFnOnce(&'a Path, bool) -> Result<()>, +{ + execute(prefix, true).await +} diff --git a/src/prefix/virgo/cache.rs b/src/environment/prefix/virgo/cache.rs similarity index 95% rename from src/prefix/virgo/cache.rs rename to src/environment/prefix/virgo/cache.rs index 79a650c..19b2394 100644 --- a/src/prefix/virgo/cache.rs +++ b/src/environment/prefix/virgo/cache.rs @@ -2,7 +2,7 @@ //! //! Each addon UUID owns an FVS filesystem layer and a separate set of forward //! registry patches. Registry hives are excluded from the layer so their changes -//! can be merged into each bottle's writable upper directory. +//! can be merged into each owner's writable upper directory. use std::{ fs, @@ -16,14 +16,13 @@ use uuid::Uuid; use crate::{ Context, - bottle::error::VirgoError, error::{Error, Result}, - prefix::FVS_BLOCK_SIZE, }; -use super::with_mount; +use super::super::FVS_BLOCK_SIZE; +use super::{VirgoError, with_mount}; -/// Removes references from one bottle's stack without deleting the shared cache. +/// Removes references from one owner's stack without deleting the shared cache. pub(super) fn remove(layers: &mut Vec, id: Uuid, context: &Context) { let repository = layer_path(id, context).display().to_string(); layers.retain(|layer| layer.repository_path != repository); @@ -131,14 +130,14 @@ where result } -/// Merges a cached addon's registry patches into a bottle's writable upper. +/// Merges a cached addon's registry patches into an owner's writable upper. /// /// A missing patch directory means the addon has no recorded registry effects. /// Both replacement hives are prepared in a scratch directory before either is /// installed, but the final renames are not atomic as a pair. Scratch cleanup is /// best-effort. pub(super) async fn apply_registry( - bottle_path: &Path, + root: &Path, layers: &[Layer], id: Uuid, context: &Context, @@ -151,8 +150,8 @@ pub(super) async fn apply_registry( return Ok(()); } - let prefix = bottle_path.join("prefix"); - let upper = bottle_path.join("upper"); + let prefix = root.join("prefix"); + let upper = root.join("upper"); with_mount( &prefix, layers.to_vec(), diff --git a/src/prefix/virgo/mod.rs b/src/environment/prefix/virgo/mod.rs similarity index 78% rename from src/prefix/virgo/mod.rs rename to src/environment/prefix/virgo/mod.rs index 98cb8cb..5f8c20d 100644 --- a/src/prefix/virgo/mod.rs +++ b/src/environment/prefix/virgo/mod.rs @@ -1,8 +1,8 @@ //! Layered Virgo prefix storage. //! -//! A mounted bottle combines a shared base, a runner-specific adapter, cached -//! addon layers, and the bottle's writable `upper` directory. Layer order is -//! persisted in [`super::Prefix`] and must be changed only while the bottle is +//! A mounted prefix combines a shared base, a runner-specific adapter, cached +//! addon layers, and the owner's writable `upper` directory. Layer order is +//! persisted in [`super::Prefix`] and must be changed only while the owner is //! stopped. mod cache; @@ -23,25 +23,52 @@ use crate::{ }; use super::FVS_BLOCK_SIZE; -use crate::bottle::error::VirgoError; + +/// Virgo-specific failures carried by [`crate::error::Error::Virgo`]. +#[derive(Debug, thiserror::Error)] +pub enum VirgoError { + /// A required FVS commit is missing from a repository. + #[error("FVS repository {repository} has no commit {state}")] + MissingCommit { + /// Repository whose history was searched. + repository: PathBuf, + /// Requested full or abbreviated state ID. + state: String, + }, + /// An existing Virgo base repository has no commits to use as a layer. + #[error("Virgo base exists but has no commits")] + EmptyBase, + /// Virgo cannot initialize a base over an existing nonempty directory. + #[error("refusing to initialize non-empty Virgo base at {0}")] + DirtyBase(PathBuf), + /// Virgo cannot mount a prefix over a nonempty mountpoint. + #[error("mountpoint is not empty: {0}")] + DirtyMountpoint(PathBuf), + /// A cached layer required to construct the prefix is missing. + #[error("cached Virgo layer was not found: {0}")] + CachedLayerNotFound(PathBuf), + /// Registry data could not be converted while building a Virgo layer. + #[error("failed to process Virgo registry data: {0}")] + Registry(String), +} pub(super) async fn create( - bottle_path: &Path, + root: &Path, runner: &dyn Runner, runner_key: &str, context: &Context, ) -> Result> { - let upper = bottle_path.join("upper"); + let upper = root.join("upper"); async_fs::create_dir_all(upper).await?; base_layers(runner, runner_key, context).await } -pub(super) async fn prepare(bottle_path: &Path, layers: &[Layer], context: &Context) -> Result<()> { - mount_layers(bottle_path, layers.to_vec(), context).await +pub(super) async fn prepare(root: &Path, layers: &[Layer], context: &Context) -> Result<()> { + mount_layers(root, layers.to_vec(), context).await } -pub(super) async fn stop(bottle_path: &Path, context: &Context) -> Result<()> { - unmount_prefix(bottle_path, context).await +pub(super) async fn stop(root: &Path, context: &Context) -> Result<()> { + unmount_prefix(root, context).await } pub(super) async fn rebuild( @@ -52,7 +79,7 @@ pub(super) async fn rebuild( context: &Context, ) -> Result<()> { // Build separately so failure to resolve any cached addon does not partially - // replace the bottle's persisted layer order. + // replace the owner's persisted layer order. let mut rebuilt = base_layers(runner, runner_key, context).await?; for id in installed { rebuilt.push(cache::layer(*id, context).await?); @@ -62,7 +89,7 @@ pub(super) async fn rebuild( } pub(super) async fn install( - bottle_path: &Path, + root: &Path, layers: &mut Vec, item_id: Uuid, replaced_id: Option, @@ -84,11 +111,11 @@ where } cache::remove(layers, item_id, context); layers.push(cached); - cache::apply_registry(bottle_path, layers, item_id, context).await + cache::apply_registry(root, layers, item_id, context).await } pub(super) async fn uninstall( - bottle_path: &Path, + root: &Path, layers: &mut Vec, item_id: Uuid, execute: F, @@ -100,8 +127,8 @@ where // Removing the layer reveals the previous filesystem contents, so the recipe // must not restore overwritten files into the writable upper directory. cache::remove(layers, item_id, context); - let prefix = bottle_path.join("prefix"); - let upper = bottle_path.join("upper"); + let prefix = root.join("prefix"); + let upper = root.join("upper"); with_mount(&prefix, layers.clone(), Some(&upper), context, async |_| { execute(&prefix, false).await }) @@ -142,12 +169,12 @@ where } } -/// Prepares a bottle's long-lived Virgo mount. +/// Prepares an owner's long-lived Virgo mount. /// /// An existing mount at the same path is trusted without comparing its layer -/// specification. Callers must stop the bottle before changing persisted layers. -async fn mount_layers(bottle_path: &Path, layers: Vec, context: &Context) -> Result<()> { - let prefix = bottle_path.join("prefix"); +/// specification. Callers must stop the owner before changing persisted layers. +async fn mount_layers(root: &Path, layers: Vec, context: &Context) -> Result<()> { + let prefix = root.join("prefix"); let mountpoint = prefix.display().to_string(); let client = context.fvs().await?; if client.list_mounts().await?.into_iter().any(|mount| { @@ -160,13 +187,13 @@ async fn mount_layers(bottle_path: &Path, layers: Vec, context: &Context) } ensure_empty_dir(&prefix).await?; client - .mount(&prefix, layers, Some(bottle_path.join("upper"))) + .mount(&prefix, layers, Some(root.join("upper"))) .await?; Ok(()) } -async fn unmount_prefix(bottle_path: &Path, context: &Context) -> Result<()> { - let mountpoint = bottle_path.join("prefix").display().to_string(); +async fn unmount_prefix(root: &Path, context: &Context) -> Result<()> { + let mountpoint = root.join("prefix").display().to_string(); let client = context.fvs().await?; if let Some(mount) = client.list_mounts().await?.into_iter().find(|mount| { mount @@ -189,7 +216,7 @@ async fn base_layers( Ok(vec![base, adapter]) } -/// Loads or creates the single base shared by every Virgo bottle. +/// Loads or creates the single base shared by every Virgo owner. /// /// Once the base repository exists, `runner` is not used. A nonempty directory /// without an FVS repository is rejected rather than overwritten. diff --git a/src/error.rs b/src/error.rs index 665ab4d..2ca0cf9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,7 @@ use thiserror::Error; #[cfg(feature = "fvs")] -pub use crate::bottle::error::VirgoError; +pub use crate::environment::prefix::VirgoError; pub use crate::{ addons::{AddonError, CatalogError, InstallerError}, bottle::error::BottleError, diff --git a/src/lib.rs b/src/lib.rs index 5614d0f..0c890d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,11 +2,11 @@ mod addons; mod bottle; mod core; mod credentials; +mod environment; pub mod error; mod library; mod operation; mod plugins; -mod prefix; mod profiles; mod runner; mod utils; @@ -20,11 +20,12 @@ pub use addons::{ pub use bottle::{ Bottle, BottleEdit, BottleError, BottleManager, BottleState, DllOverride, DllOverrideMode, GamescopeConfig, GamescopeFilter, GamescopeScaler, MangoHudConfig, Process, ProgramSpec, - RegistryHive, Storage, Wrappers, + RegistryHive, Wrappers, }; #[cfg(feature = "fvs")] pub use bottle::{Snapshot, SnapshotSummary}; pub use core::{Bottles, Config}; +pub use environment::Storage; pub use library::{Library, LibraryItem, SearchEntry, SearchSource}; pub use operation::{Operation, Progress, Stage, Transfer}; pub use plugins::{PluginError, PluginId, PluginInfo, PluginKind, PluginManifest, Plugins}; diff --git a/src/prefix/standard.rs b/src/prefix/standard.rs deleted file mode 100644 index 2857d3a..0000000 --- a/src/prefix/standard.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! Direct mutable prefix storage. -//! -//! Recipes operate on `/prefix`. Uninstallation asks the recipe to -//! restore overwritten files because, unlike Virgo, this backend has no lower -//! layer to reveal. Transaction rollback is provided by the parent module. - -use std::{ops::AsyncFnOnce, path::Path}; - -use crate::{ - error::Result, - runner::{Runner, initialize_and_shutdown_prefix}, -}; - -pub(super) async fn create(bottle_path: &Path, runner: &dyn Runner) -> Result<()> { - initialize_and_shutdown_prefix(runner, &bottle_path.join("prefix")).await -} - -pub(super) async fn install(bottle_path: &Path, execute: F) -> Result<()> -where - F: for<'a> AsyncFnOnce(&'a Path) -> Result<()>, -{ - execute(&bottle_path.join("prefix")).await -} - -pub(super) async fn uninstall(bottle_path: &Path, execute: F) -> Result<()> -where - F: for<'a> AsyncFnOnce(&'a Path, bool) -> Result<()>, -{ - execute(&bottle_path.join("prefix"), true).await -} From 509aa8485b4c1ea7d34c45c10bcf138c5fc524f7 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Thu, 10 Sep 2026 23:00:33 +0530 Subject: [PATCH 03/24] refactor(core): introduce private owner-cached environments --- README.md | 13 + src/bottle/edit.rs | 19 +- src/bottle/error.rs | 34 -- src/bottle/manager.rs | 32 +- src/bottle/mod.rs | 2 +- src/bottle/snapshot.rs | 17 +- src/bottle/software.rs | 568 ++++++---------------------- src/bottle/state.rs | 211 ++--------- src/bottle/tests.rs | 18 +- src/environment/config.rs | 115 ++++++ src/environment/error.rs | 40 ++ src/environment/mod.rs | 163 +++++++- src/environment/prefix/mod.rs | 233 +++++------- src/environment/prefix/virgo/mod.rs | 2 +- src/environment/software.rs | 242 ++++++++++++ src/error.rs | 3 + src/lib.rs | 2 +- src/library.rs | 6 +- 18 files changed, 892 insertions(+), 828 deletions(-) create mode 100644 src/environment/config.rs create mode 100644 src/environment/error.rs create mode 100644 src/environment/software.rs diff --git a/README.md b/README.md index 07bbc06..c818b3a 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,19 @@ The crate is centered around six types: - `Operation` represents long-running work with progress and cooperative cancellation. +Execution settings live in `BottleState::environment()` as an +`EnvironmentConfig`. Each bottle retains a private environment on first runtime +use; clones share it. Registered programs use the bottle's settings. Use +`Bottle::launch(ProgramSpec)` to run an unregistered executable and +`Bottle::launch_program(uuid)` to run a registration. Both return `Operation` +with the initial Windows process ID. Dropping the bottle only detaches; call +`stop()` to stop its runtime. + +Bottle configuration requires execution settings under `environment`, with +resolved FVS layers retained inside `environment.storage` for Virgo. Old +configurations are rejected during deserialization and left untouched; recreate +those bottles to use the new format. Completed addon caches remain reusable. + Operations are lazy. Await them, call `cancel().await`, or spawn them and explicitly detach the task; dropping an operation abandons it. diff --git a/src/bottle/edit.rs b/src/bottle/edit.rs index d118df5..625e0df 100644 --- a/src/bottle/edit.rs +++ b/src/bottle/edit.rs @@ -7,6 +7,7 @@ use super::{ state::{Bottle, ProgramSpec}, }; use crate::{ + EnvironmentError, error::Result, wrapper::{gamescope::GamescopeConfig, mangohud::MangoHudConfig}, }; @@ -123,24 +124,24 @@ impl BottleEdit { pub async fn commit(self) -> Result<()> { let BottleEdit { bottle, changes } = self; bottle - .update(None, async move |state, _| { + .update(None, async move |state, _, _| { for change in changes { match change { Change::Rename(name) => state.name = name, Change::SetEnv(key, value) => { if key.is_empty() || key.contains('=') || key.contains('\0') { - return Err(BottleError::InvalidEnvironmentName(key).into()); + return Err(EnvironmentError::InvalidEnvironmentName(key).into()); } if value.contains('\0') { - return Err(BottleError::InvalidEnvironmentValue(key).into()); + return Err(EnvironmentError::InvalidEnvironmentValue(key).into()); } - state.env_vars.insert(key, value); + state.environment.env_vars.insert(key, value); } Change::UnsetEnv(key) => { if key.is_empty() || key.contains('=') || key.contains('\0') { - return Err(BottleError::InvalidEnvironmentName(key).into()); + return Err(EnvironmentError::InvalidEnvironmentName(key).into()); } - state.env_vars.remove(&key); + state.environment.env_vars.remove(&key); } Change::AddProgram(program) => { state.programs.insert(program.id(), program); @@ -151,8 +152,10 @@ impl BottleEdit { .remove(&id) .ok_or(BottleError::ProgramNotFound(id))?; } - Change::SetGamescope(config) => state.wrappers.gamescope = config, - Change::SetMangoHud(config) => state.wrappers.mangohud = config, + Change::SetGamescope(config) => { + state.environment.wrappers.gamescope = config + } + Change::SetMangoHud(config) => state.environment.wrappers.mangohud = config, } } Ok(()) diff --git a/src/bottle/error.rs b/src/bottle/error.rs index 84b1ffa..c64caf9 100644 --- a/src/bottle/error.rs +++ b/src/bottle/error.rs @@ -3,8 +3,6 @@ use thiserror::Error; use uuid::Uuid; -use crate::{Requirement, Slot}; - /// Bottle-specific failures carried by [`crate::error::Error::Bottle`]. #[derive(Debug, Error)] pub enum BottleError { @@ -28,39 +26,7 @@ pub enum BottleError { /// A program definition is malformed. #[error("invalid program: {0}")] InvalidProgram(String), - /// An environment variable name is empty or contains `=` or NUL. - #[error( - "invalid environment variable name {0:?}: names must be non-empty and contain neither '=' nor NUL" - )] - InvalidEnvironmentName(String), - /// An environment variable value contains NUL. - #[error("environment variable {0:?} contains NUL in its value")] - InvalidEnvironmentValue(String), - /// A DLL name is empty or contains NUL. - /// - /// This variant is reserved for local validation. The current DLL override - /// methods delegate validation to WineBridge and return - /// [`crate::error::Error::Status`] instead. - #[error("DLL name {0:?} must be non-empty and contain no NUL bytes")] - InvalidDllName(String), - /// [`crate::DllOverrideMode::Unspecified`] was passed as an override mode. - #[error("DLL override mode is required")] - DllOverrideModeRequired, /// No program is registered with the requested UUID. #[error("program {0} was not found")] ProgramNotFound(Uuid), - /// No selected component occupies the requested slot. - #[error("component slot {0:?} is not installed")] - ComponentNotInstalled(Slot), - /// One or more dependencies must be downloaded or installed before the operation. - #[error("addon requirements are not satisfied: {requirements:?}")] - RequiresAddon { - /// Release requesting the dependencies, or `None` for bottle creation. - required_by: Option, - /// Every currently unsatisfied requirement. - requirements: Vec, - }, - /// A bottle operation received a component for a different role. - #[error("component {component} must occupy slot {required:?}")] - InvalidComponentSlot { component: Uuid, required: Slot }, } diff --git a/src/bottle/manager.rs b/src/bottle/manager.rs index d53597e..387d3c4 100644 --- a/src/bottle/manager.rs +++ b/src/bottle/manager.rs @@ -19,9 +19,9 @@ use uuid::Uuid; #[cfg(feature = "fvs")] use crate::environment::prefix::FVS_BLOCK_SIZE; use crate::{ - Context, Operation, Progress, Stage, Storage, + Context, EnvironmentConfig, EnvironmentError, Operation, Progress, Stage, Storage, addons::{Addon, Addons, Requirement, Slot}, - environment::prefix::Prefix, + environment::prefix, error::{Error, Result}, }; @@ -155,13 +155,13 @@ impl BottleManager { /// /// # Errors /// - /// Returns [`BottleError::RequiresAddon`] with every missing runtime + /// Returns [`EnvironmentError::RequiresAddon`] with every missing runtime /// requirement before creating any files. Other service, I/O, and prefix /// creation failures are returned directly. pub fn create( &self, name: impl Into, - storage: Storage, + mut storage: Storage, runner: Uuid, ) -> Operation { let name = name.into(); @@ -174,7 +174,7 @@ impl BottleManager { .component(runner) .ok_or(crate::AddonError::NotFound(runner))?; if runner_component.slot() != Slot::Runner { - return Err(BottleError::InvalidComponentSlot { + return Err(EnvironmentError::InvalidComponentSlot { component: runner_component.id(), required: Slot::Runner, } @@ -195,7 +195,7 @@ impl BottleManager { missing.push(Requirement::Slot(Slot::Umu)); } if !missing.is_empty() { - return Err(BottleError::RequiresAddon { + return Err(EnvironmentError::RequiresAddon { required_by: None, requirements: missing, } @@ -211,8 +211,8 @@ impl BottleManager { let result = async { progress.send_replace(Some(Progress::new(Stage::CreatingPrefix))); - let storage = Prefix::create( - storage, + prefix::create( + &mut storage, &bottle_path, loaded_runner.as_ref(), &runner_component.id().to_string(), @@ -233,9 +233,13 @@ impl BottleManager { let bottle = Bottle::new( id, name, - components, - Vec::new(), - storage, + EnvironmentConfig { + storage, + components, + dependencies: Vec::new(), + env_vars: Default::default(), + wrappers: Default::default(), + }, cx.clone(), addons.clone(), ) @@ -279,8 +283,8 @@ impl BottleManager { let manager = self.clone(); Operation::new(move |progress, cancellation| async move { let bottle = manager.open(id).await?; - let _write = cancellation - .run_until_cancelled(bottle.0.write_lock.write()) + let mut environment = cancellation + .run_until_cancelled(bottle.0.environment.lock()) .await .ok_or(Error::Cancelled)?; if cancellation.is_cancelled() { @@ -288,7 +292,7 @@ impl BottleManager { } let state = bottle.state()?; progress.send_replace(Some(Progress::new(Stage::Stopping))); - Bottle::stop_state(&state, &bottle.0.cx).await?; + Bottle::stop_state(&state, &bottle.0.cx, &mut environment).await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } diff --git a/src/bottle/mod.rs b/src/bottle/mod.rs index c6f88f9..d590d8b 100644 --- a/src/bottle/mod.rs +++ b/src/bottle/mod.rs @@ -18,7 +18,7 @@ //! caller-visible snapshots and rollback checkpoints around addon changes. //! Long-running mutations return lazy //! [`crate::Operation`] values and serialize with edits, stopping, snapshots, -//! and deletion. WineBridge-backed requests may run concurrently. +//! and deletion. WineBridge-backed control calls share that coordination. mod edit; pub(crate) mod error; diff --git a/src/bottle/snapshot.rs b/src/bottle/snapshot.rs index e771c51..5bb9a89 100644 --- a/src/bottle/snapshot.rs +++ b/src/bottle/snapshot.rs @@ -40,8 +40,8 @@ impl Bottle { let cx = self.0.cx.clone(); let message = message.into(); Operation::new(move |progress, cancellation| async move { - let _write = cancellation - .run_until_cancelled(bottle.0.write_lock.write()) + let mut environment = cancellation + .run_until_cancelled(bottle.0.environment.lock()) .await .ok_or(Error::Cancelled)?; if cancellation.is_cancelled() { @@ -49,7 +49,7 @@ impl Bottle { } let state = bottle.state()?; progress.send_replace(Some(Progress::new(Stage::Stopping))); - Bottle::stop_state(&state, &cx).await?; + Bottle::stop_state(&state, &cx, &mut environment).await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } @@ -70,15 +70,14 @@ impl Bottle { /// `bottles-next:auto-checkpoint` is excluded because that value is reserved /// for internal mutation checkpoints. /// - /// Listing holds shared bottle access: WineBridge requests may continue, - /// while edits, stop, snapshot mutation, and deletion wait. + /// Listing serializes with runtime control, edits and deletion. /// /// # Errors /// /// Returns an error if the bottle was deleted, the FVS service is /// unavailable, or its snapshot history cannot be read. pub async fn snapshots(&self) -> Result> { - let _read = self.0.write_lock.read().await; + let _read = self.0.environment.lock().await; self.ensure_exists()?; let repository = self.snapshot_repository(); Ok(self @@ -123,8 +122,8 @@ impl Bottle { let cx = self.0.cx.clone(); let state_id_or_prefix = state_id_or_prefix.to_owned(); Operation::new(move |progress, cancellation| async move { - let _write = cancellation - .run_until_cancelled(bottle.0.write_lock.write()) + let mut environment = cancellation + .run_until_cancelled(bottle.0.environment.lock()) .await .ok_or(Error::Cancelled)?; if cancellation.is_cancelled() { @@ -132,7 +131,7 @@ impl Bottle { } let state = bottle.state()?; progress.send_replace(Some(Progress::new(Stage::Stopping))); - Bottle::stop_state(&state, &cx).await?; + Bottle::stop_state(&state, &cx, &mut environment).await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } diff --git a/src/bottle/software.rs b/src/bottle/software.rs index ad7be1d..77f7aad 100644 --- a/src/bottle/software.rs +++ b/src/bottle/software.rs @@ -1,535 +1,211 @@ -//! Runtime, process, runner, and addon operations on [`Bottle`]. +//! Public bottle operations coordinated around a private cached environment. -use std::future::Future; +use std::ops::AsyncFnOnce; use tokio_util::sync::CancellationToken; use uuid::Uuid; +use super::{Bottle, BottleState, error::BottleError}; use crate::{ - Context, Operation, Progress, Stage, - addons::{ - Addon, Artifact, InstallInputs, Requirement, Slot, execute, replay_env_vars, uninstall, - }, + Context, Operation, ProgramSpec, Progress, Slot, Stage, + environment::Environment, error::{Error, Result}, proto::{DllOverride, DllOverrideMode, Process}, - runner::shutdown_prefix, - winebridge::WineBridgeClient, -}; - -use super::{ - error::BottleError, - state::{Bottle, BottleState}, }; impl Bottle { - /// Lists DLL overrides configured in this bottle's Wine registry. - /// - /// This starts WineBridge if necessary. A missing override registry key is - /// treated as an empty list, but malformed values are returned as errors. - /// The result order is unspecified. Reading registry state does not publish - /// a new [`BottleState`] or notify [`Bottle::watch`](Self::watch). - /// - /// # Errors - /// - /// Returns an error if the bottle was deleted, its prefix cannot be - /// prepared, WineBridge cannot start, or the request fails. + /// Lists Wine DLL overrides, starting the environment if necessary. pub async fn dll_overrides(&self) -> Result> { - self.with_bridge(|bridge| async move { - match bridge.list_dll_overrides().await { - Ok(overrides) => Ok(overrides), - Err(Error::Status(status)) if status.code() == tonic::Code::NotFound => { - Ok(Vec::new()) - } - Err(error) => Err(error), - } + self.with_environment(None, async |_, environment| { + environment.dll_overrides().await }) .await } - /// Sets the Wine loading mode for `dll`. - /// - /// This starts WineBridge if necessary and replaces any existing mode for - /// the same DLL name. The registry change does not publish a new - /// [`BottleState`] or notify [`Bottle::watch`](Self::watch). - /// - /// # Errors - /// - /// Returns [`BottleError::DllOverrideModeRequired`] for - /// [`DllOverrideMode::Unspecified`]. Empty or NUL-containing DLL names are - /// currently rejected by WineBridge as [`Error::Status`]. Prefix and bridge - /// failures are also returned. + /// Sets a Wine DLL loading mode, starting the environment if necessary. pub async fn set_dll_override( &self, dll: impl Into, mode: DllOverrideMode, ) -> Result<()> { let dll = dll.into(); - if mode == DllOverrideMode::Unspecified { - return Err(BottleError::DllOverrideModeRequired.into()); - } - self.with_bridge(move |bridge| async move { bridge.set_dll_override(dll, mode).await }) - .await + self.with_environment(None, async move |_, environment| { + environment.set_dll_override(dll, mode).await + }) + .await } - /// Removes the Wine loading override for `dll`. - /// - /// Removing an override that is not present succeeds. This starts - /// WineBridge if necessary. The registry change does not publish a new - /// [`BottleState`] or notify [`Bottle::watch`](Self::watch). - /// - /// # Errors - /// - /// Empty or NUL-containing DLL names are currently rejected by WineBridge - /// as [`Error::Status`]. Prefix and bridge failures are also returned. + /// Removes a Wine DLL override. Removing a missing override succeeds. pub async fn unset_dll_override(&self, dll: impl Into) -> Result<()> { let dll = dll.into(); - self.with_bridge(move |bridge| async move { - match bridge.delete_dll_override(dll).await { - Err(Error::Status(status)) if status.code() == tonic::Code::NotFound => Ok(()), - result => result, - } + self.with_environment(None, async move |_, environment| { + environment.unset_dll_override(dll).await }) .await } - /// Launches a registered program and returns its Windows process ID. - /// - /// The program definition is copied before this call waits for shared - /// bottle access. A concurrent edit therefore does not change or cancel - /// this launch. WineBridge starts on demand, and the returned ID identifies - /// the initially launched Windows process. Repeated launches with the same - /// program UUID share the process group targeted by - /// [`kill_program`](Self::kill_program). - /// - /// # Errors + /// Launches the latest registration with this bottle's execution settings. /// - /// Returns [`BottleError::ProgramNotFound`] if `id` is not registered, or an - /// error if the prefix cannot be prepared, WineBridge cannot start, or the - /// process cannot be launched. - pub async fn launch_program(&self, id: Uuid) -> Result { - let program = self - .state()? - .program(id) - .cloned() - .ok_or(BottleError::ProgramNotFound(id))?; - let executable = program.executable().to_owned(); - let arguments = program.args().to_vec(); - let working_directory = program.working_directory().map(str::to_owned); - let new_console = program.new_console(); - self.with_bridge(move |bridge| async move { - bridge - .launch_process(id, executable, arguments, working_directory, new_console) + /// The lazy operation resolves the definition under the owner lock and returns + /// the initial Windows process ID. The process continues after it completes. + pub fn launch_program(&self, id: Uuid) -> Operation { + self.launch_with(move |state| { + state + .program(id) + .cloned() + .ok_or_else(|| BottleError::ProgramNotFound(id).into()) + }) + } + + /// Runs an unregistered launch definition with this bottle's settings. + /// This does not add a library entry. The UUID still identifies its process group. + pub fn launch(&self, program: ProgramSpec) -> Operation { + self.launch_with(move |_| Ok(program)) + } + + fn launch_with( + &self, + resolve: impl FnOnce(&BottleState) -> Result + Send + 'static, + ) -> Operation { + let bottle = self.clone(); + Operation::new(move |progress, cancellation| async move { + progress.send_replace(Some(Progress::new(Stage::Preparing))); + bottle + .with_environment(Some(&cancellation), async |state, environment| { + let program = resolve(state)?; + environment.launch(&program, &cancellation).await + }) .await }) - .await } - /// Returns a snapshot of Windows processes visible in the bottle. - /// - /// This includes processes not launched from a registered [`crate::ProgramSpec`]. - /// WineBridge starts if necessary, and no ordering guarantee is made. - /// - /// # Errors - /// - /// Returns an error if the prefix cannot be prepared, WineBridge cannot - /// start, or the process snapshot cannot be read. + /// Returns Windows processes, starting the environment if necessary. pub async fn processes(&self) -> Result> { - self.with_bridge(|bridge| async move { bridge.list_processes().await }) + self.with_environment(None, async |_, environment| environment.processes().await) .await } - /// Terminates the process group associated with a registered program. - /// - /// Every running process assigned to the UUID-keyed group is terminated. - /// This starts WineBridge if necessary; if the program is registered but - /// has no running group members, the request succeeds. - /// - /// # Errors - /// - /// Returns [`BottleError::ProgramNotFound`] if `id` is not registered, or an - /// error if the prefix or bridge operation fails. + /// Terminates a registered program's UUID-keyed process group. + /// This starts the environment if necessary and leaves it available afterward. pub async fn kill_program(&self, id: Uuid) -> Result<()> { - if self.state()?.program(id).is_none() { - return Err(BottleError::ProgramNotFound(id).into()); - } - self.with_bridge(move |bridge| async move { bridge.kill_process(id).await }) - .await + self.with_environment(None, async move |state, environment| { + if state.program(id).is_none() { + return Err(BottleError::ProgramNotFound(id).into()); + } + environment.kill(id).await + }) + .await } - /// Stops WineBridge, wineserver, and prefix storage. - /// - /// This waits for in-flight bridge-backed calls to finish and does not - /// return until all three cleanup actions have been attempted. Cleanup - /// continues after a failure and the first error is returned. No - /// configuration state is changed or published. - /// - /// After a successful stop, the next bridge-backed operation applies the - /// latest environment and wrapper configuration. + /// Stops WineBridge, wineserver and storage, then clears the cached environment. + /// Cleanup attempts every action and returns the first error. pub async fn stop(&self) -> Result<()> { - let _write = self.0.write_lock.write().await; + let mut environment = self.0.environment.lock().await; let state = self.state()?; - Self::stop_state(&state, &self.0.cx).await + Self::stop_state(&state, &self.0.cx, &mut environment).await } - /// Selects or replaces one downloaded component. - /// - /// The operation checks the proposed complete bottle state before mutation. - /// Switching to Proton selects the newest downloaded UMU when necessary; - /// switching to Wine removes the unused UMU selection. The current - /// downloaded component with the supplied UUID is authoritative. + /// Selects a downloaded component after validating the complete configuration. + /// Runtime changes stop the environment before changing prefix storage. pub fn set_component(&self, id: Uuid) -> Operation<()> { let bottle = self.clone(); let addons = self.0.addons.clone(); Operation::new(move |progress, cancellation| async move { bottle - .update(Some(&cancellation), async |state, cx| { - let component = addons - .component(id) - .ok_or(crate::AddonError::NotFound(id))?; - if state - .component(component.slot()) - .is_some_and(|installed| installed.id() == component.id()) - { - return Ok(()); - } - - let mut candidate = state.clone(); - let needs_umu = component - .requirements() - .contains(&Requirement::Slot(Slot::Umu)); - if needs_umu && candidate.umu().is_none() { - let umu = addons.latest_component(Slot::Umu).ok_or_else(|| { - BottleError::RequiresAddon { - required_by: Some(component.id()), - requirements: vec![Requirement::Slot(Slot::Umu)], - } - })?; - candidate - .components - .insert(Slot::Umu, Addon::from(umu.as_ref())); - } - candidate - .components - .insert(component.slot(), Addon::from(component.as_ref())); - if component.slot() == Slot::Runner && !needs_umu { - candidate.components.remove(&Slot::Umu); - } - candidate.validate_requirements()?; - - if component.slot().is_runtime() { - progress.send_replace(Some(Progress::new(Stage::Stopping))); - Self::stop_state(state, &cx).await?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - let rebuild = component.slot() == Slot::Runner; - *state = candidate; - if rebuild { - progress.send_replace(Some(Progress::new(Stage::Rebuilding))); - let installed = state - .components - .values() - .filter(|component| !component.slot().is_runtime()) - .map(Addon::id) - .chain(state.dependencies.iter().map(Addon::id)) - .collect::>(); - let runner = state - .runner() - .load_runner(cx.directories(), state.umu()) - .await?; - state - .storage - .rebuild( - runner.as_ref(), - &state.runner().id().to_string(), - &installed, - &cx, - ) - .await?; - } - return Ok(()); - } - - let replaced_id = state.component(component.slot()).map(Addon::id); - let resources = vec![component.artifact(cx.directories())]; - Self::install_item( - state, - &cx, - component.id(), - replaced_id, - resources, - |state| *state = candidate, - progress, - &cancellation, - ) - .await + .update_environment(&cancellation, async |environment| { + environment + .set_component(id, &addons, &progress, &cancellation) + .await }) .await }) } - /// Removes the component occupying `slot`. - /// - /// The operation is rejected when removing the component would violate a - /// bottle or installed-addon requirement. Prefix recipe reversal remains - /// best effort and does not require the catalog or downloaded files. + /// Removes a component unless another selected addon requires it. pub fn remove_component(&self, slot: Slot) -> Operation<()> { let bottle = self.clone(); Operation::new(move |progress, cancellation| async move { bottle - .update(Some(&cancellation), async |state, cx| { - let component = state - .component(slot) - .cloned() - .ok_or(BottleError::ComponentNotInstalled(slot))?; - let mut candidate = state.clone(); - candidate.components.remove(&slot); - candidate.validate_requirements()?; - let item_id = component.id(); - let resources = vec![component.artifact(cx.directories())]; - let winebridge = state.winebridge().path(cx.directories()); - let prefix_progress = progress.clone(); - Self::stop_state(state, &cx).await?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - *state = candidate; - let runner = state - .runner() - .load_runner(cx.directories(), state.umu()) - .await?; - let bottle_path = cx.directories().bottle(state.id); - let context = cx.clone(); - let BottleState { - storage, env_vars, .. - } = state; - storage - .uninstall( - &bottle_path, - item_id, - async |prefix, restore_files| { - uninstall( - InstallInputs { - prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars, - }, - &resources, - restore_files, - item_id, - &cancellation, - move |_| { - progress.send_replace(Some(Progress::new(Stage::Removing))); - }, - ) - .await - }, - &context, - &cancellation, - move |event| { - prefix_progress.send_replace(Some(event)); - }, - ) + .update_environment(&cancellation, async |environment| { + environment + .remove_component(slot, &progress, &cancellation) .await }) .await }) } - /// Permanently installs one downloaded dependency into this bottle. - /// - /// Reinstalling the same release is idempotent. Dependencies remain - /// recorded for the bottle's lifetime and cannot be uninstalled separately. - /// The current downloaded dependency with the supplied UUID is authoritative. + /// Permanently installs a downloaded dependency. Reinstalling its UUID is a no-op. pub fn install(&self, id: Uuid) -> Operation<()> { let bottle = self.clone(); let addons = self.0.addons.clone(); Operation::new(move |progress, cancellation| async move { bottle - .update(Some(&cancellation), async |state, cx| { - let dependency = addons - .dependency(id) - .ok_or(crate::AddonError::NotFound(id))?; - if state.dependency(dependency.id()).is_some() { - return Ok(()); - } - let mut candidate = state.clone(); - candidate - .dependencies - .push(Addon::from(dependency.as_ref())); - candidate.validate_requirements()?; - let resources = dependency - .artifacts() - .iter() - .map(|artifact| { - Artifact::new( - dependency.path(cx.directories()).join(&artifact.path), - artifact.steps.clone(), - ) - }) - .collect(); - Self::install_item( - state, - &cx, - dependency.id(), - None, - resources, - |state| *state = candidate, - progress, - &cancellation, - ) - .await + .update_environment(&cancellation, async |environment| { + environment + .install(id, &addons, &progress, &cancellation) + .await }) .await }) } - #[allow(clippy::too_many_arguments)] - /// Runs the shared, checkpointed addon mutation while the caller holds - /// exclusive bottle access. - /// - /// The draft configuration is updated before prefix work but is persisted - /// only by [`Bottle::update`] after this returns. Virgo can satisfy an - /// installation from a cached layer without executing the recipe, so its - /// environment steps are replayed into the draft to produce the same - /// persisted configuration as a fresh installation. - async fn install_item( - state: &mut BottleState, - cx: &Context, - item_id: Uuid, - replaced_id: Option, - resources: Vec, - update_config: F, - progress: tokio::sync::watch::Sender>, - cancellation: &CancellationToken, - ) -> Result<()> + async fn update_environment(&self, cancellation: &CancellationToken, work: F) -> Result<()> where - F: FnOnce(&mut BottleState), + F: for<'a> AsyncFnOnce(&'a mut Environment) -> Result<()>, { - Self::stop_state(state, cx).await?; - update_config(state); - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } + self.update(Some(cancellation), async |state, cx, cached| { + // A failed mutation must not leave its candidate configuration cached. + let mut environment = cached + .take() + .unwrap_or_else(|| Self::new_environment(state, &cx)); + work(&mut environment).await?; + state.environment = environment.config.clone(); + *cached = Some(environment); + Ok(()) + }) + .await + } - let runner = state - .runner() - .load_runner(cx.directories(), state.umu()) - .await?; - let winebridge = state.winebridge().path(cx.directories()); - let bottle_path = cx.directories().bottle(state.id); - let context = cx.clone(); - let BottleState { - storage, env_vars, .. - } = state; - let step_progress = progress.clone(); - storage - .install( - &bottle_path, - item_id, - replaced_id, - async |prefix| { - execute( - InstallInputs { - prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars, - }, - &resources, - cancellation, - move |_| { - step_progress.send_replace(Some(Progress::new(Stage::Configuring))); - }, - ) - .await - }, - &context, - cancellation, - move |event| { - progress.send_replace(Some(event)); - }, - ) - .await?; - replay_env_vars(env_vars, &resources); + pub(super) async fn stop_state( + state: &BottleState, + cx: &Context, + cached: &mut Option, + ) -> Result<()> { + let environment = cached.get_or_insert_with(|| Self::new_environment(state, cx)); + environment.stop().await?; + *cached = None; Ok(()) } - /// Performs uncancellable lifecycle cleanup while exclusive bottle access - /// prevents new bridge work. - /// - /// WineBridge shutdown, runner shutdown, and storage unmount are all - /// attempted; the first error is retained. - pub(super) async fn stop_state(state: &BottleState, cx: &Context) -> Result<()> { - let bottle_path = cx.directories().bottle(state.id); - let prefix_path = bottle_path.join("prefix"); - let runner = state - .runner() - .load_runner(cx.directories(), state.umu()) - .await; - let storage = state.storage.clone(); - let mut first_error = None; - match WineBridgeClient::try_connect(&prefix_path).await { - Ok(Some(bridge)) => { - if let Err(error) = bridge.shutdown().await { - first_error.get_or_insert(error); - } - } - Ok(None) => {} - Err(error) => { - first_error.get_or_insert(error); - } - } - - let runner = match runner { - Ok(runner) => Some(runner), - Err(error) => { - first_error.get_or_insert(error); - None - } - }; - if let Some(runner) = runner.as_deref() - && let Err(error) = shutdown_prefix(runner, &prefix_path).await - { - first_error.get_or_insert(error); - } - if let Err(error) = storage.stop(&bottle_path, cx).await { - first_error.get_or_insert(error); - } - first_error.map_or(Ok(()), Err) + fn new_environment(state: &BottleState, cx: &Context) -> Environment { + Environment::new( + state.environment.clone(), + cx.directories().bottle(state.id), + cx.clone(), + ) } - /// Holds shared bottle access while preparing the persisted prefix and - /// performing one WineBridge request. - /// - /// Environment and wrappers come from one published state snapshot, and - /// WineBridge remains running afterward. Shared access permits concurrent - /// requests but currently does not coalesce simultaneous first starts. - async fn with_bridge(&self, work: F) -> Result + async fn with_environment( + &self, + cancellation: Option<&CancellationToken>, + work: F, + ) -> Result where - F: FnOnce(WineBridgeClient) -> Fut, - Fut: Future>, + F: for<'a, 'b> AsyncFnOnce(&'a BottleState, &'b mut Environment) -> Result, { - let _read = self.0.write_lock.read().await; + let mut cached = match cancellation { + Some(cancellation) => cancellation + .run_until_cancelled(self.0.environment.lock()) + .await + .ok_or(Error::Cancelled)?, + None => self.0.environment.lock().await, + }; + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err(Error::Cancelled); + } let state = self.state()?; - let runner = state - .runner() - .load_runner(self.0.cx.directories(), state.umu()) - .await?; - let bottle_path = self.bottle_path(); - let prefix = self.prefix_path(); - let storage = state.storage.clone(); - let cx = self.0.cx.clone(); - let command = state.wrappers.apply( - WineBridgeClient::command( - runner.as_ref(), - &prefix, - state.winebridge().path(self.0.cx.directories()), - ) - .envs(state.env_vars.iter()), - ); - storage.prepare(&bottle_path, &cx).await?; - work(WineBridgeClient::connect_or_spawn(&prefix, command).await?).await + let environment = cached.get_or_insert_with(|| Self::new_environment(&state, &self.0.cx)); + work(&state, environment).await } } diff --git a/src/bottle/state.rs b/src/bottle/state.rs index 281a4ae..316a1a2 100644 --- a/src/bottle/state.rs +++ b/src/bottle/state.rs @@ -4,26 +4,26 @@ use std::{ collections::HashMap, hash::{Hash, Hasher}, ops::AsyncFnOnce, - path::PathBuf, sync::Arc, }; +#[cfg(feature = "fvs")] +use std::path::PathBuf; + use futures_core::Stream; use next_config::Config; use serde::{Deserialize, Serialize}; -use tokio::sync::{RwLock, watch}; +use tokio::sync::{Mutex, watch}; use tokio_stream::{StreamExt, wrappers::WatchStream}; use tokio_util::sync::CancellationToken; use uuid::Uuid; use super::{edit::BottleEdit, error::BottleError}; use crate::{ - Context, Storage, - addons::{Addon, Addons, Component, Dependency, Requirement, Slot}, - environment::prefix::Prefix, + Context, EnvironmentConfig, + addons::Addons, + environment::Environment, error::{Error, Result}, - utils::env_vars::EnvVars, - wrapper::Wrappers, }; /// An immutable snapshot of a bottle's published configuration. @@ -38,19 +38,9 @@ use crate::{ pub struct BottleState { pub(crate) id: Uuid, pub(crate) name: String, - pub(crate) storage: Prefix, + pub(crate) environment: EnvironmentConfig, #[serde(default)] pub(crate) programs: HashMap, - - /// Runtime and prefix components pinned to exact releases. - pub(crate) components: HashMap>, - /// Installed dependency releases. - pub(crate) dependencies: Vec>, - #[serde(default, skip_serializing_if = "EnvVars::is_empty")] - pub(crate) env_vars: EnvVars, - - #[serde(flatten)] - pub(crate) wrappers: Wrappers, } impl BottleState { @@ -66,123 +56,9 @@ impl BottleState { &self.name } - /// Returns the runner recorded when this snapshot was published. - /// - /// Catalog refreshes do not replace this value. - pub fn runner(&self) -> &Addon { - self.component(Slot::Runner) - .expect("persisted bottle state is runtime-validated") - } - - /// Returns the exact WineBridge release selected for this bottle. - pub fn winebridge(&self) -> &Addon { - self.component(Slot::WineBridge) - .expect("persisted bottle state is runtime-validated") - } - - /// Returns the selected UMU release, if this runtime uses one. - pub fn umu(&self) -> Option<&Addon> { - self.component(Slot::Umu) - } - - /// Returns exact component releases keyed by their occupied slots. - pub fn components(&self) -> &HashMap> { - &self.components - } - - /// Returns every dependency installed in this bottle. - pub fn dependencies(&self) -> &[Addon] { - &self.dependencies - } - - /// Returns the component occupying `slot`, if any. - pub fn component(&self, slot: Slot) -> Option<&Addon> { - self.components.get(&slot) - } - - /// Returns the installed dependency with this release identifier. - pub fn dependency(&self, id: Uuid) -> Option<&Addon> { - self.dependencies - .iter() - .find(|dependency| dependency.id() == id) - } - - pub(crate) fn contains_addon_matching(&self, requirement: &Requirement) -> bool { - self.components - .values() - .any(|component| component.satisfies(requirement)) - || self - .dependencies - .iter() - .any(|dependency| dependency.satisfies(requirement)) - } - - pub(crate) fn validate_requirements(&self) -> Result<()> { - for (slot, component) in &self.components { - if component.slot() != *slot { - return Err(BottleError::InvalidComponentSlot { - component: component.id(), - required: *slot, - } - .into()); - } - } - - let missing = [Slot::WineBridge, Slot::Runner] - .into_iter() - .filter(|slot| self.component(*slot).is_none()) - .map(Requirement::Slot) - .collect::>(); - if !missing.is_empty() { - return Err(BottleError::RequiresAddon { - required_by: None, - requirements: missing, - } - .into()); - } - - for (id, requirements) in self - .components - .values() - .map(|addon| (addon.id(), addon.requirements())) - .chain( - self.dependencies - .iter() - .map(|addon| (addon.id(), addon.requirements())), - ) - { - let missing = requirements - .iter() - .filter(|requirement| !self.contains_addon_matching(requirement)) - .cloned() - .collect::>(); - if !missing.is_empty() { - return Err(BottleError::RequiresAddon { - required_by: Some(id), - requirements: missing, - } - .into()); - } - } - Ok(()) - } - - /// Returns environment variables supplied when WineBridge is started. - /// - /// Changes do not affect an already-running WineBridge. Call - /// [`Bottle::stop`] before the next bridge-backed operation to apply them - /// immediately. - pub fn env_vars(&self) -> &EnvVars { - &self.env_vars - } - - /// Returns the wrapper configuration applied when WineBridge is started. - /// - /// Changes do not affect an already-running WineBridge. Call - /// [`Bottle::stop`] before the next bridge-backed operation to apply them - /// immediately. - pub fn wrappers(&self) -> &Wrappers { - &self.wrappers + /// Returns the execution settings shared by every registration in this bottle. + pub fn environment(&self) -> &EnvironmentConfig { + &self.environment } /// Iterates over registered programs in unspecified order. @@ -194,22 +70,14 @@ impl BottleState { pub fn program(&self, id: Uuid) -> Option<&ProgramSpec> { self.programs.get(&id) } - - /// Reports how the Wine prefix itself is stored. - /// - /// With the default `fvs` feature, both strategies use FVS for snapshot - /// history and addon mutation checkpoints. - pub fn storage(&self) -> Storage { - self.storage.kind() - } } /// The shared coordination state behind cloned [`Bottle`] handles. pub(crate) struct BottleInner { /// Latest state; `None` is the tombstone published when the bottle is deleted. pub(crate) published: watch::Sender>>, - /// Excludes metadata and destructive operations while bridge calls hold shared access. - pub(crate) write_lock: RwLock<()>, + /// Serializes control operations and retains the lazily created runtime. + pub(crate) environment: Mutex>, /// Retained after deletion so stale handles report which bottle was deleted. pub(crate) id: Uuid, /// Shared services and storage locations scoped to the owning manager. @@ -226,10 +94,9 @@ pub(crate) struct BottleInner { /// Hashing identifies the shared live handle and remains stable across state /// publications and deletion. /// -/// Methods that access WineBridge start it on demand. Once it is running, -/// requests may run concurrently. As a current limitation, callers must -/// serialize simultaneous first bridge-backed calls for a stopped bottle to -/// avoid racing two WineBridge starts. +/// Runtime operations share a lazily created private environment. Control calls +/// serialize within this core instance; the lock is released after launch, not +/// when the guest process exits. Dropping handles does not stop Wine. #[derive(Clone)] pub struct Bottle(pub(crate) Arc); @@ -247,21 +114,15 @@ impl Bottle { pub(crate) async fn new( id: Uuid, name: String, - components: HashMap>, - dependencies: Vec>, - storage: Prefix, + environment: EnvironmentConfig, context: Context, addons: Addons, ) -> Result { let state = BottleState { id, name, - components, - dependencies, - storage, + environment, programs: HashMap::new(), - wrappers: Wrappers::default(), - env_vars: EnvVars::default(), }; let bottle = Self::from_state(state, context, addons)?; bottle.save().await?; @@ -270,13 +131,13 @@ impl Bottle { /// Reconstructs a live handle after validating its addon requirements. pub(crate) fn from_state(state: BottleState, cx: Context, addons: Addons) -> Result { - state.validate_requirements()?; + state.environment.validate_requirements()?; let id = state.id; let (published, _) = watch::channel(Some(Arc::new(state))); Ok(Self(Arc::new(BottleInner { id, published, - write_lock: RwLock::new(()), + environment: Mutex::new(None), cx, addons, }))) @@ -354,21 +215,36 @@ impl Bottle { operation: F, ) -> Result where - F: for<'a> AsyncFnOnce(&'a mut BottleState, Context) -> Result, + F: for<'a, 'b> AsyncFnOnce( + &'a mut BottleState, + Context, + &'b mut Option, + ) -> Result, { - let _write = match cancellation { + let mut environment = match cancellation { Some(cancellation) => cancellation - .run_until_cancelled(self.0.write_lock.write()) + .run_until_cancelled(self.0.environment.lock()) .await .ok_or(Error::Cancelled)?, - None => self.0.write_lock.write().await, + None => self.0.environment.lock().await, }; if cancellation.is_some_and(CancellationToken::is_cancelled) { return Err(Error::Cancelled); } let mut draft = self.state()?.as_ref().clone(); - let value = operation(&mut draft, self.0.cx.clone()).await?; - Self::save_state(&draft, &self.0.cx).await?; + let value = operation(&mut draft, self.0.cx.clone(), &mut environment).await?; + if let Err(error) = Self::save_state(&draft, &self.0.cx).await { + *environment = None; + return Err(error); + } + // Configuration edits apply at the next runtime start. Detaching leaves + // an already-running Wine instance available for sequential reconnect. + if environment + .as_ref() + .is_some_and(|runtime| runtime.config != draft.environment) + { + *environment = None; + } self.publish(draft); Ok(value) } @@ -387,14 +263,11 @@ impl Bottle { }); } + #[cfg(feature = "fvs")] pub(crate) fn bottle_path(&self) -> PathBuf { self.0.cx.directories().bottle(self.0.id) } - pub(crate) fn prefix_path(&self) -> PathBuf { - self.bottle_path().join("prefix") - } - async fn save(&self) -> Result<()> { let state = self.state()?; Self::save_state(&state, &self.0.cx).await diff --git a/src/bottle/tests.rs b/src/bottle/tests.rs index 2613ca0..788738d 100644 --- a/src/bottle/tests.rs +++ b/src/bottle/tests.rs @@ -3,14 +3,14 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, }; -use tokio::sync::{RwLock, watch}; +use tokio::sync::{Mutex, watch}; use tokio_util::sync::CancellationToken; use super::state::BottleInner; use crate::{ - Context, Directories, Storage, + Context, Directories, EnvironmentError, Storage, addons::{AddonError, Addons, CatalogError, Requirement, Slot}, - bottle::{Bottle, BottleManager, error::BottleError}, + bottle::{Bottle, BottleManager}, error::Error, }; fn test_directories() -> Directories { @@ -29,7 +29,7 @@ async fn deleted_bottle() -> (Bottle, Directories) { let (published, _) = watch::channel(None); let bottle = Bottle(Arc::new(BottleInner { published, - write_lock: RwLock::new(()), + environment: Mutex::new(None), id: uuid::Uuid::new_v4(), cx: context, addons, @@ -41,11 +41,11 @@ async fn deleted_bottle() -> (Bottle, Directories) { fn bottle_update_cancels_while_waiting_for_write_lock() { futures_lite::future::block_on(async { let (bottle, directories) = deleted_bottle().await; - let write = bottle.0.write_lock.write().await; + let write = bottle.0.environment.lock().await; let cancellation = CancellationToken::new(); let ran = Arc::new(AtomicBool::new(false)); let work_ran = ran.clone(); - let mut update = Box::pin(bottle.update(Some(&cancellation), async move |_, _| { + let mut update = Box::pin(bottle.update(Some(&cancellation), async move |_, _, _| { work_ran.store(true, Ordering::Relaxed); Ok(()) })); @@ -67,11 +67,11 @@ fn bottle_update_cancels_while_waiting_for_write_lock() { fn bottle_update_rechecks_cancellation_when_lock_becomes_available() { futures_lite::future::block_on(async { let (bottle, directories) = deleted_bottle().await; - let write = bottle.0.write_lock.write().await; + let write = bottle.0.environment.lock().await; let cancellation = CancellationToken::new(); let ran = Arc::new(AtomicBool::new(false)); let work_ran = ran.clone(); - let mut update = Box::pin(bottle.update(Some(&cancellation), async move |_, _| { + let mut update = Box::pin(bottle.update(Some(&cancellation), async move |_, _, _| { work_ran.store(true, Ordering::Relaxed); Ok(()) })); @@ -154,7 +154,7 @@ fn create_reports_all_missing_runtime_addons_before_creating_files() { }; assert!(matches!( error, - Error::Bottle(BottleError::RequiresAddon { + Error::Environment(EnvironmentError::RequiresAddon { required_by: None, requirements, }) if requirements == vec![ diff --git a/src/environment/config.rs b/src/environment/config.rs new file mode 100644 index 0000000..625d971 --- /dev/null +++ b/src/environment/config.rs @@ -0,0 +1,115 @@ +//! Persisted execution settings shared by all environment owners. + +use super::{EnvironmentError, Storage}; +use crate::{Addon, Component, Dependency, EnvVars, Requirement, Slot, Wrappers, error::Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; + +/// Execution settings embedded in a bottle or standalone program's saved state. +/// Storage retains resolved Virgo layers; live runtime resources are never persisted. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct EnvironmentConfig { + pub storage: Storage, + /// Component releases pinned to their occupied slots. + pub components: HashMap>, + /// Installed dependencies in installation order. + pub dependencies: Vec>, + #[serde(default, skip_serializing_if = "EnvVars::is_empty")] + pub env_vars: EnvVars, + #[serde(default)] + pub wrappers: Wrappers, +} + +impl EnvironmentConfig { + /// Returns the runner recorded when this snapshot was published. + /// + /// Catalog refreshes do not replace this value. + pub fn runner(&self) -> &Addon { + self.component(Slot::Runner) + .expect("persisted environment configuration is validated") + } + + /// Returns the exact WineBridge release selected for this environment. + pub fn winebridge(&self) -> &Addon { + self.component(Slot::WineBridge) + .expect("persisted environment configuration is validated") + } + + /// Returns the selected UMU release, if this runtime uses one. + pub fn umu(&self) -> Option<&Addon> { + self.component(Slot::Umu) + } + + /// Returns the component occupying `slot`, if any. + pub fn component(&self, slot: Slot) -> Option<&Addon> { + self.components.get(&slot) + } + + /// Returns the installed dependency with this release identifier. + pub fn dependency(&self, id: Uuid) -> Option<&Addon> { + self.dependencies + .iter() + .find(|dependency| dependency.id() == id) + } + + fn contains_addon_matching(&self, requirement: &Requirement) -> bool { + self.components + .values() + .any(|component| component.satisfies(requirement)) + || self + .dependencies + .iter() + .any(|dependency| dependency.satisfies(requirement)) + } + + pub(crate) fn validate_requirements(&self) -> Result<()> { + for (slot, component) in &self.components { + if component.slot() != *slot { + return Err(EnvironmentError::InvalidComponentSlot { + component: component.id(), + required: *slot, + } + .into()); + } + } + + let missing = [Slot::WineBridge, Slot::Runner] + .into_iter() + .filter(|slot| self.component(*slot).is_none()) + .map(Requirement::Slot) + .collect::>(); + if !missing.is_empty() { + return Err(EnvironmentError::RequiresAddon { + required_by: None, + requirements: missing, + } + .into()); + } + + for (id, requirements) in self + .components + .values() + .map(|addon| (addon.id(), addon.requirements())) + .chain( + self.dependencies + .iter() + .map(|addon| (addon.id(), addon.requirements())), + ) + { + let missing = requirements + .iter() + .filter(|requirement| !self.contains_addon_matching(requirement)) + .cloned() + .collect::>(); + if !missing.is_empty() { + return Err(EnvironmentError::RequiresAddon { + required_by: Some(id), + requirements: missing, + } + .into()); + } + } + Ok(()) + } +} diff --git a/src/environment/error.rs b/src/environment/error.rs new file mode 100644 index 0000000..69fdb6f --- /dev/null +++ b/src/environment/error.rs @@ -0,0 +1,40 @@ +use crate::{Requirement, Slot}; +use thiserror::Error; +use uuid::Uuid; + +/// Failures in shared execution configuration and operations. +#[derive(Debug, Error)] +pub enum EnvironmentError { + /// An environment variable name is empty or contains `=` or NUL. + #[error( + "invalid environment variable name {0:?}: names must be non-empty and contain neither '=' nor NUL" + )] + InvalidEnvironmentName(String), + /// An environment variable value contains NUL. + #[error("environment variable {0:?} contains NUL in its value")] + InvalidEnvironmentValue(String), + /// A DLL name is empty or contains NUL. + /// + /// This variant is reserved for local validation. The current DLL override + /// methods delegate validation to WineBridge and return + /// [`crate::error::Error::Status`] instead. + #[error("DLL name {0:?} must be non-empty and contain no NUL bytes")] + InvalidDllName(String), + /// [`crate::DllOverrideMode::Unspecified`] was passed as an override mode. + #[error("DLL override mode is required")] + DllOverrideModeRequired, + /// No selected component occupies the requested slot. + #[error("component slot {0:?} is not installed")] + ComponentNotInstalled(Slot), + /// One or more dependencies must be downloaded or installed before the operation. + #[error("addon requirements are not satisfied: {requirements:?}")] + RequiresAddon { + /// Release requesting the dependencies, or `None` for environment creation. + required_by: Option, + /// Every currently unsatisfied requirement. + requirements: Vec, + }, + /// An environment operation received a component for a different role. + #[error("component {component} must occupy slot {required:?}")] + InvalidComponentSlot { component: Uuid, required: Slot }, +} diff --git a/src/environment/mod.rs b/src/environment/mod.rs index 3f8e26c..7381205 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -1,5 +1,166 @@ -//! Shared execution environment implementation. +//! Shared execution configuration and the private runtime retained by an owner. +mod config; +mod error; pub(crate) mod prefix; +mod software; +use std::path::PathBuf; + +use tokio_util::sync::CancellationToken; + +use crate::{ + Context, ProgramSpec, + error::{Error, Result}, + proto::{DllOverride, DllOverrideMode, Process}, + runner::{Runner, shutdown_prefix}, + winebridge::WineBridgeClient, +}; + +pub use config::EnvironmentConfig; +pub use error::EnvironmentError; pub use prefix::Storage; + +/// A private owner-cached handle. Dropping it only releases local resources. +/// Owners serialize access and persist configuration, including storage metadata. +pub(crate) struct Environment { + pub(crate) config: EnvironmentConfig, + root: PathBuf, + cx: Context, + runner: Option>, + bridge: Option, +} + +impl Environment { + pub(crate) fn new(config: EnvironmentConfig, root: PathBuf, cx: Context) -> Self { + Self { + config, + root, + cx, + runner: None, + bridge: None, + } + } + + async fn load_runner(&mut self) -> Result<()> { + if self.runner.is_none() { + self.runner = Some( + self.config + .runner() + .load_runner(self.cx.directories(), self.config.umu()) + .await?, + ); + } + Ok(()) + } + + async fn bridge(&mut self) -> Result<&WineBridgeClient> { + if self.bridge.is_none() { + self.load_runner().await?; + prefix::prepare(&self.config.storage, &self.root, &self.cx).await?; + let prefix = self.root.join("prefix"); + let command = self.config.wrappers.apply( + WineBridgeClient::command( + self.runner.as_deref().expect("runner loaded"), + &prefix, + self.config.winebridge().path(self.cx.directories()), + ) + .envs(self.config.env_vars.iter()), + ); + self.bridge = Some(WineBridgeClient::connect_or_spawn(&prefix, command).await?); + } + Ok(self.bridge.as_ref().expect("bridge connected")) + } + + pub(crate) async fn launch( + &mut self, + program: &ProgramSpec, + cancellation: &CancellationToken, + ) -> Result { + let bridge = self.bridge().await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + bridge + .launch_process( + program.id(), + program.executable().to_owned(), + program.args().to_vec(), + program.working_directory().map(str::to_owned), + program.new_console(), + ) + .await + } + + pub(crate) async fn processes(&mut self) -> Result> { + self.bridge().await?.list_processes().await + } + + pub(crate) async fn kill(&mut self, id: uuid::Uuid) -> Result<()> { + self.bridge().await?.kill_process(id).await + } + + pub(crate) async fn dll_overrides(&mut self) -> Result> { + match self.bridge().await?.list_dll_overrides().await { + Ok(overrides) => Ok(overrides), + Err(Error::Status(status)) if status.code() == tonic::Code::NotFound => Ok(Vec::new()), + Err(error) => Err(error), + } + } + + pub(crate) async fn set_dll_override( + &mut self, + dll: String, + mode: DllOverrideMode, + ) -> Result<()> { + if mode == DllOverrideMode::Unspecified { + return Err(EnvironmentError::DllOverrideModeRequired.into()); + } + self.bridge().await?.set_dll_override(dll, mode).await + } + + pub(crate) async fn unset_dll_override(&mut self, dll: String) -> Result<()> { + match self.bridge().await?.delete_dll_override(dll).await { + Err(Error::Status(status)) if status.code() == tonic::Code::NotFound => Ok(()), + result => result, + } + } + + /// Attempts every cleanup action, retaining the first error for explicit retry. + pub(crate) async fn stop(&mut self) -> Result<()> { + let prefix = self.root.join("prefix"); + let runner_loaded = self.load_runner().await; + let mut first_error = None; + // Discovery also supports stopping a runtime left by a previous client. + match WineBridgeClient::try_connect(&prefix).await { + Ok(Some(bridge)) => { + if let Err(error) = bridge.shutdown().await { + first_error.get_or_insert(error); + } + } + Ok(None) => {} + Err(error) => { + first_error.get_or_insert(error); + } + } + match runner_loaded { + Ok(()) => { + if let Err(error) = + shutdown_prefix(self.runner.as_deref().expect("runner loaded"), &prefix).await + { + first_error.get_or_insert(error); + } + } + Err(error) => { + first_error.get_or_insert(error); + } + } + if let Err(error) = prefix::stop(&self.config.storage, &self.root, &self.cx).await { + first_error.get_or_insert(error); + } + first_error.map_or(Ok(()), Err)?; + self.bridge = None; + self.runner = None; + Ok(()) + } +} diff --git a/src/environment/prefix/mod.rs b/src/environment/prefix/mod.rs index 7f08e1c..ab47cf2 100644 --- a/src/environment/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -1,8 +1,7 @@ //! Prefix storage backends and checkpointed addon mutation. //! -//! [`Prefix`] is persisted as part of its owner's state. Standard storage -//! mutates a conventional prefix directly; Virgo stores an ordered FVS layer -//! stack with a private writable upper directory. With the default `fvs` +//! Standard storage mutates a conventional prefix directly; Virgo stores an +//! ordered FVS layer stack with a private writable upper directory. With the default `fvs` //! feature, addon installation and removal use an FVS rollback checkpoint. mod standard; @@ -40,7 +39,7 @@ pub(crate) const AUTO_CHECKPOINT_MESSAGE: &str = "bottles-next:auto-checkpoint"; pub(crate) const FVS_BLOCK_SIZE: u32 = 1024 * 1024; /// Selects conventional mutable storage or FVS composition. -#[derive(Debug, Clone, Copy, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] pub enum Storage { /// Stores a conventional mutable prefix in the owner directory. /// @@ -51,19 +50,11 @@ pub enum Storage { /// /// Virgo is experimental and requires the configured FVS service. #[cfg(feature = "fvs")] - Virgo, -} - -/// Persisted storage selection and resolved immutable layer references. -/// -/// This record owns no processes, mounts, or connections. -#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub(crate) struct Prefix { - kind: Storage, - /// Mount order: shared base, runner adapter, then installed addon layers. - #[cfg(feature = "fvs")] - #[serde(default, skip_serializing_if = "Vec::is_empty")] - layers: Vec, + Virgo { + /// Resolved layer order retained until composition is derived from settings. + #[serde(default)] + layers: Vec, + }, } #[cfg(feature = "fvs")] @@ -78,136 +69,114 @@ impl From<&FvsProgress> for Transfer { } } -impl Prefix { - pub(crate) async fn create( - storage: Storage, - root: &Path, - runner: &dyn Runner, - runner_key: &str, - context: &Context, - ) -> Result { - #[cfg(not(feature = "fvs"))] - let _ = (runner_key, context); - match storage { - Storage::Standard => { - standard::create(&root.join("prefix"), runner).await?; - Ok(Self { - kind: storage, - #[cfg(feature = "fvs")] - layers: Vec::new(), - }) - } - #[cfg(feature = "fvs")] - Storage::Virgo => Ok(Self { - kind: storage, - layers: virgo::create(root, runner, runner_key, context).await?, - }), +/// Creates storage at an explicit owner location. +pub(crate) async fn create( + storage: &mut Storage, + root: &Path, + runner: &dyn Runner, + runner_key: &str, + context: &Context, +) -> Result<()> { + #[cfg(not(feature = "fvs"))] + let _ = (runner_key, context); + match storage { + Storage::Standard => standard::create(&root.join("prefix"), runner).await, + #[cfg(feature = "fvs")] + Storage::Virgo { layers } => { + *layers = virgo::create(root, runner, runner_key, context).await?; + Ok(()) } } +} - pub(crate) fn kind(&self) -> Storage { - self.kind +pub(crate) async fn prepare(storage: &Storage, root: &Path, context: &Context) -> Result<()> { + let _ = (root, context); + match storage { + Storage::Standard => Ok(()), + #[cfg(feature = "fvs")] + Storage::Virgo { layers } => virgo::prepare(root, layers, context).await, } +} - pub(crate) async fn prepare(&self, root: &Path, context: &Context) -> Result<()> { - let _ = (root, context); - match self.kind { - Storage::Standard => Ok(()), - #[cfg(feature = "fvs")] - Storage::Virgo => virgo::prepare(root, &self.layers, context).await, - } +pub(crate) async fn stop(storage: &Storage, root: &Path, context: &Context) -> Result<()> { + let _ = (root, context); + match storage { + Storage::Standard => Ok(()), + #[cfg(feature = "fvs")] + Storage::Virgo { .. } => virgo::stop(root, context).await, } +} - pub(crate) async fn stop(&self, root: &Path, context: &Context) -> Result<()> { - let _ = (root, context); - match self.kind { - Storage::Standard => Ok(()), - #[cfg(feature = "fvs")] - Storage::Virgo => virgo::stop(root, context).await, +pub(crate) async fn rebuild( + storage: &mut Storage, + runner: &dyn Runner, + runner_key: &str, + installed: &[Uuid], + context: &Context, +) -> Result<()> { + match storage { + Storage::Standard => { + let _ = (runner, runner_key, installed, context); + Ok(()) + } + #[cfg(feature = "fvs")] + Storage::Virgo { layers } => { + virgo::rebuild(layers, runner, runner_key, installed, context).await } } +} - pub(crate) async fn rebuild( - &mut self, - runner: &dyn Runner, - runner_key: &str, - installed: &[Uuid], - context: &Context, - ) -> Result<()> { - match self.kind { - Storage::Standard => { - let _ = (runner, runner_key, installed, context); - Ok(()) - } +pub(crate) async fn install( + storage: &mut Storage, + root: &Path, + item_id: Uuid, + replaced_id: Option, + execute: F, + context: &Context, + cancellation: &CancellationToken, + on_progress: P, +) -> Result<()> +where + F: for<'a> std::ops::AsyncFnOnce(&'a Path) -> Result<()>, + P: FnMut(Progress), +{ + let _ = (item_id, replaced_id); + let work = async { + match storage { + Storage::Standard => standard::install(&root.join("prefix"), execute).await, #[cfg(feature = "fvs")] - Storage::Virgo => { - // Resolve the complete replacement before changing persisted state. A - // missing cached addon therefore leaves the old layer stack intact. - virgo::rebuild(&mut self.layers, runner, runner_key, installed, context).await + Storage::Virgo { layers } => { + virgo::install(root, layers, item_id, replaced_id, execute, context).await } } - } - - pub(crate) async fn install( - &mut self, - root: &Path, - item_id: Uuid, - replaced_id: Option, - execute: F, - context: &Context, - cancellation: &CancellationToken, - on_progress: P, - ) -> Result<()> - where - F: for<'a> std::ops::AsyncFnOnce(&'a Path) -> Result<()>, - P: FnMut(Progress), - { - let _ = (item_id, replaced_id); - let work = async { - match self.kind { - Storage::Standard => standard::install(&root.join("prefix"), execute).await, - #[cfg(feature = "fvs")] - Storage::Virgo => { - virgo::install( - root, - &mut self.layers, - item_id, - replaced_id, - execute, - context, - ) - .await - } - } - }; - transact(root, context, work, cancellation, on_progress).await - } + }; + transact(root, context, work, cancellation, on_progress).await +} - pub(crate) async fn uninstall( - &mut self, - root: &Path, - item_id: Uuid, - execute: F, - context: &Context, - cancellation: &CancellationToken, - on_progress: P, - ) -> Result<()> - where - F: for<'a> std::ops::AsyncFnOnce(&'a Path, bool) -> Result<()>, - P: FnMut(Progress), - { - let _ = item_id; - let work = async { - match self.kind { - Storage::Standard => standard::uninstall(&root.join("prefix"), execute).await, - #[cfg(feature = "fvs")] - Storage::Virgo => { - virgo::uninstall(root, &mut self.layers, item_id, execute, context).await - } +pub(crate) async fn uninstall( + storage: &mut Storage, + root: &Path, + item_id: Uuid, + execute: F, + context: &Context, + cancellation: &CancellationToken, + on_progress: P, +) -> Result<()> +where + F: for<'a> std::ops::AsyncFnOnce(&'a Path, bool) -> Result<()>, + P: FnMut(Progress), +{ + let _ = item_id; + let work = async { + match storage { + Storage::Standard => standard::uninstall(&root.join("prefix"), execute).await, + #[cfg(feature = "fvs")] + Storage::Virgo { layers } => { + virgo::uninstall(root, layers, item_id, execute, context).await } - }; - transact(root, context, work, cancellation, on_progress).await - } + } + }; + transact(root, context, work, cancellation, on_progress).await } /// Runs a prefix mutation behind a rollback checkpoint. diff --git a/src/environment/prefix/virgo/mod.rs b/src/environment/prefix/virgo/mod.rs index 5f8c20d..74996d6 100644 --- a/src/environment/prefix/virgo/mod.rs +++ b/src/environment/prefix/virgo/mod.rs @@ -2,7 +2,7 @@ //! //! A mounted prefix combines a shared base, a runner-specific adapter, cached //! addon layers, and the owner's writable `upper` directory. Layer order is -//! persisted in [`super::Prefix`] and must be changed only while the owner is +//! persisted by the owner and must be changed only while the owner is //! stopped. mod cache; diff --git a/src/environment/software.rs b/src/environment/software.rs new file mode 100644 index 0000000..037ec31 --- /dev/null +++ b/src/environment/software.rs @@ -0,0 +1,242 @@ +//! Addon operations on an owner's execution configuration and prefix data. + +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::{Environment, EnvironmentConfig, EnvironmentError, prefix}; +use crate::{ + Addon, AddonError, Addons, Progress, Requirement, Slot, Stage, + addons::{Artifact, InstallInputs, execute, replay_env_vars, uninstall}, + error::{Error, Result}, +}; + +impl Environment { + pub(crate) async fn set_component( + &mut self, + id: Uuid, + addons: &Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, + ) -> Result<()> { + let component = addons.component(id).ok_or(AddonError::NotFound(id))?; + if self + .config + .component(component.slot()) + .is_some_and(|installed| installed.id() == id) + { + return Ok(()); + } + let mut candidate = self.config.clone(); + let needs_umu = component + .requirements() + .contains(&Requirement::Slot(Slot::Umu)); + if needs_umu && candidate.umu().is_none() { + let umu = addons.latest_component(Slot::Umu).ok_or_else(|| { + EnvironmentError::RequiresAddon { + required_by: Some(id), + requirements: vec![Requirement::Slot(Slot::Umu)], + } + })?; + candidate + .components + .insert(Slot::Umu, Addon::from(umu.as_ref())); + } + candidate + .components + .insert(component.slot(), Addon::from(component.as_ref())); + if component.slot() == Slot::Runner && !needs_umu { + candidate.components.remove(&Slot::Umu); + } + candidate.validate_requirements()?; + + if component.slot().is_runtime() { + progress.send_replace(Some(Progress::new(Stage::Stopping))); + self.stop().await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + self.config = candidate; + if component.slot() == Slot::Runner { + progress.send_replace(Some(Progress::new(Stage::Rebuilding))); + let installed = self + .config + .components + .values() + .filter(|component| !component.slot().is_runtime()) + .map(Addon::id) + .chain(self.config.dependencies.iter().map(Addon::id)) + .collect::>(); + let runner = self + .config + .runner() + .load_runner(self.cx.directories(), self.config.umu()) + .await?; + let runner_key = self.config.runner().id().to_string(); + prefix::rebuild( + &mut self.config.storage, + runner.as_ref(), + &runner_key, + &installed, + &self.cx, + ) + .await?; + } + return Ok(()); + } + let replaced_id = self.config.component(component.slot()).map(Addon::id); + let resources = vec![component.artifact(self.cx.directories())]; + self.install_item( + candidate, + id, + replaced_id, + resources, + progress, + cancellation, + ) + .await + } + + pub(crate) async fn remove_component( + &mut self, + slot: Slot, + progress: &watch::Sender>, + cancellation: &CancellationToken, + ) -> Result<()> { + let component = self + .config + .component(slot) + .cloned() + .ok_or(EnvironmentError::ComponentNotInstalled(slot))?; + let mut candidate = self.config.clone(); + candidate.components.remove(&slot); + candidate.validate_requirements()?; + let item_id = component.id(); + let resources = vec![component.artifact(self.cx.directories())]; + let winebridge = self.config.winebridge().path(self.cx.directories()); + self.stop().await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + self.config = candidate; + let runner = self + .config + .runner() + .load_runner(self.cx.directories(), self.config.umu()) + .await?; + let env_vars = &mut self.config.env_vars; + prefix::uninstall( + &mut self.config.storage, + &self.root, + item_id, + async |prefix, restore_files| { + uninstall( + InstallInputs { + prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars, + }, + &resources, + restore_files, + item_id, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Removing))); + }, + ) + .await + }, + &self.cx, + cancellation, + |event| { + progress.send_replace(Some(event)); + }, + ) + .await + } + + pub(crate) async fn install( + &mut self, + id: Uuid, + addons: &Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, + ) -> Result<()> { + let dependency = addons.dependency(id).ok_or(AddonError::NotFound(id))?; + if self.config.dependency(id).is_some() { + return Ok(()); + } + let mut candidate = self.config.clone(); + candidate + .dependencies + .push(Addon::from(dependency.as_ref())); + candidate.validate_requirements()?; + let resources = dependency + .artifacts() + .iter() + .map(|artifact| { + Artifact::new( + dependency.path(self.cx.directories()).join(&artifact.path), + artifact.steps.clone(), + ) + }) + .collect(); + self.install_item(candidate, id, None, resources, progress, cancellation) + .await + } + + /// The owner persists these changes only after the complete operation succeeds. + async fn install_item( + &mut self, + candidate: EnvironmentConfig, + item_id: Uuid, + replaced_id: Option, + resources: Vec, + progress: &watch::Sender>, + cancellation: &CancellationToken, + ) -> Result<()> { + self.stop().await?; + self.config = candidate; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let runner = self + .config + .runner() + .load_runner(self.cx.directories(), self.config.umu()) + .await?; + let winebridge = self.config.winebridge().path(self.cx.directories()); + let env_vars = &mut self.config.env_vars; + prefix::install( + &mut self.config.storage, + &self.root, + item_id, + replaced_id, + async |prefix| { + execute( + InstallInputs { + prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars, + }, + &resources, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Configuring))); + }, + ) + .await + }, + &self.cx, + cancellation, + |event| { + progress.send_replace(Some(event)); + }, + ) + .await?; + replay_env_vars(env_vars, &resources); + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs index 2ca0cf9..72d2fc6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,6 +5,7 @@ pub use crate::environment::prefix::VirgoError; pub use crate::{ addons::{AddonError, CatalogError, InstallerError}, bottle::error::BottleError, + environment::EnvironmentError, plugins::PluginError, profiles::ProfileError, runner::RunnerError, @@ -37,6 +38,8 @@ pub enum Error { Fvs(#[from] FvsError), #[error("Bottle error: {0}")] Bottle(#[from] BottleError), + #[error("Environment error: {0}")] + Environment(#[from] EnvironmentError), #[cfg(feature = "fvs")] #[error("Virgo error: {0}")] Virgo(#[from] VirgoError), diff --git a/src/lib.rs b/src/lib.rs index 0c890d5..3b90421 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,7 +25,7 @@ pub use bottle::{ #[cfg(feature = "fvs")] pub use bottle::{Snapshot, SnapshotSummary}; pub use core::{Bottles, Config}; -pub use environment::Storage; +pub use environment::{EnvironmentConfig, EnvironmentError, Storage}; pub use library::{Library, LibraryItem, SearchEntry, SearchSource}; pub use operation::{Operation, Progress, Stage, Transfer}; pub use plugins::{PluginError, PluginId, PluginInfo, PluginKind, PluginManifest, Plugins}; diff --git a/src/library.rs b/src/library.rs index 4ba5a29..aaa8984 100644 --- a/src/library.rs +++ b/src/library.rs @@ -10,7 +10,7 @@ use futures_util::{ use uuid::Uuid; use crate::{ - Bottle, BottleManager, PluginId, PluginKind, Plugins, Profiles, ProgramSpec, + Bottle, BottleManager, Operation, PluginId, PluginKind, Plugins, Profiles, ProgramSpec, bottle::error::BottleError, credentials, error::Result, }; @@ -203,8 +203,8 @@ impl LibraryItem { } /// Launches the current registration. - pub async fn launch(&self) -> Result { - self.bottle.launch_program(self.program_id).await + pub fn launch(&self) -> Operation { + self.bottle.launch_program(self.program_id) } /// Kills the current registration's process group. From ce59a48b038c1c24d44cc31b8317e1565a549003 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Thu, 10 Sep 2026 23:12:30 +0530 Subject: [PATCH 04/24] refactor(core): replace BottleEdit with owner edit callbacks --- README.md | 19 +- src/bottle/edit.rs | 224 +++++++------------- src/bottle/mod.rs | 8 +- src/bottle/software.rs | 103 +++++----- src/bottle/state.rs | 54 ++--- src/environment/error.rs | 6 + src/environment/mod.rs | 26 ++- src/environment/prefix/mod.rs | 23 +++ src/environment/software.rs | 375 +++++++++++++++------------------- src/lib.rs | 5 +- src/runner/mod.rs | 3 +- src/runner/proton.rs | 8 +- src/utils/env_vars.rs | 4 +- src/winebridge.rs | 2 +- 14 files changed, 406 insertions(+), 454 deletions(-) diff --git a/README.md b/README.md index c818b3a..ad16573 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,15 @@ use; clones share it. Registered programs use the bottle's settings. Use with the initial Windows process ID. Dropping the bottle only detaches; call `stop()` to stop its runtime. +`Bottle::edit(|state| { /* changes */ Ok(()) })` returns an `Operation<()>`. +The callback receives a draft of the latest state under the owner lock. Edit +`name`, `programs`, and `environment` directly; errors discard the whole draft. +Metadata edits work while running. Call `stop()` before changing environment +settings, including through `set_component`, `remove_component`, or `install`. +Storage is fixed at creation; existing dependencies remain in installation order. +New dependency selections can be appended. Prefix changes run before publication; +batch rollback of those effects remains part of the later composition work. + Bottle configuration requires execution settings under `environment`, with resolved FVS layers retained inside `environment.storage` for Virgo. Old configurations are rejected during deserialization and left untouched; recreate @@ -79,10 +88,12 @@ async fn main() -> Result<(), bottles_core::error::Error> { if let Some(bottle) = bottles.bottles().list().into_iter().next() { let program = ProgramSpec::new("Example", "C:/Games/example.exe")?; - let mut edit = bottle.edit(); - edit.add_program(program.clone()); - edit.commit().await?; - println!("registered {} as {}", program.name(), program.id()); + let id = program.id(); + bottle.edit(move |state| { + state.programs.insert(program.id(), program); + Ok(()) + }).await?; + println!("registered {id}"); } let mut installed = Box::pin(bottles.library().watch()); diff --git a/src/bottle/edit.rs b/src/bottle/edit.rs index 625e0df..ac51f31 100644 --- a/src/bottle/edit.rs +++ b/src/bottle/edit.rs @@ -1,165 +1,85 @@ -//! Batched edits to persisted bottle configuration. +//! Coordinated edits to the latest persisted bottle configuration. -use uuid::Uuid; - -use super::{ - error::BottleError, - state::{Bottle, ProgramSpec}, -}; +use super::{Bottle, BottleError, BottleState}; use crate::{ - EnvironmentError, - error::Result, - wrapper::{gamescope::GamescopeConfig, mangohud::MangoHudConfig}, + Operation, Progress, Stage, + error::{Error, Result}, }; -#[must_use = "edits do nothing unless committed"] -/// A pending batch of configuration changes for a [`Bottle`]. -/// -/// Builder methods only queue changes. [`commit`](Self::commit) applies them in -/// order to a draft of the latest state available when the commit acquires -/// exclusive access; it does not capture the state that existed when -/// [`Bottle::edit`] was called. The draft is published only after it has been -/// persisted. Dropping an edit without committing it has no effect. -pub struct BottleEdit { - bottle: Bottle, - changes: Vec, -} - -/// One mutation queued by [`BottleEdit`]; vector order is commit order. -enum Change { - Rename(String), - SetEnv(String, String), - UnsetEnv(String), - AddProgram(ProgramSpec), - RemoveProgram(Uuid), - SetGamescope(GamescopeConfig), - SetMangoHud(MangoHudConfig), -} - -impl BottleEdit { - pub(super) fn new(bottle: Bottle) -> Self { - Self { - bottle, - changes: Vec::new(), - } - } - - /// Changes the bottle's display name. - /// - /// Names are stored verbatim, may be empty, and need not be unique. - pub fn rename(&mut self, name: impl Into) -> &mut Self { - self.changes.push(Change::Rename(name.into())); - self - } - - /// Sets an environment variable for future WineBridge starts. - /// - /// This does not change an already-running WineBridge. Call [`Bottle::stop`] - /// before the next bridge-backed operation to apply it immediately. - /// Values stored here are applied after runner-provided variables, so they - /// can override values such as `WINEPREFIX`, `WINEARCH`, and `PROTONPATH`. - /// - /// At commit time, names must be nonempty and contain neither `=` nor NUL; - /// values must not contain NUL. Case and whitespace are preserved, and - /// lookup is case-sensitive. - pub fn set_env(&mut self, key: &str, value: &str) -> &mut Self { - self.changes - .push(Change::SetEnv(key.to_owned(), value.to_owned())); - self - } - - /// Removes an environment variable for future WineBridge starts. - /// - /// Removing a missing variable succeeds. Names have the same validation - /// and case-sensitive matching rules as [`set_env`](Self::set_env). - pub fn unset_env(&mut self, key: &str) -> &mut Self { - self.changes.push(Change::UnsetEnv(key.to_owned())); - self - } - - /// Registers a program. - pub fn add_program(&mut self, program: ProgramSpec) -> &mut Self { - self.changes.push(Change::AddProgram(program)); - self - } - - /// Removes the program identified by `id`. +impl Bottle { + /// Applies a callback to a draft of the latest state when this operation runs. /// - /// The edit fails to commit if the program is not registered. - pub fn remove_program(&mut self, id: Uuid) -> &mut Self { - self.changes.push(Change::RemoveProgram(id)); - self - } - - /// Replaces the Gamescope configuration used for future WineBridge starts. + /// Edit `name`, `programs`, and `environment` directly. Returning an error + /// discards the draft. Valid changes are reconciled, persisted, then published + /// together; cloned handles serialize edits against the latest state. /// - /// If WineBridge is already running, stop the bottle after committing so - /// that the next bridge-backed operation starts it with the new wrapper. - pub fn set_gamescope(&mut self, config: GamescopeConfig) -> &mut Self { - self.changes.push(Change::SetGamescope(config)); - self - } - - /// Replaces the MangoHud configuration used for future WineBridge starts. - /// - /// If WineBridge is already running, stop the bottle after committing so - /// that the next bridge-backed operation starts it with the new wrapper. - pub fn set_mangohud(&mut self, config: MangoHudConfig) -> &mut Self { - self.changes.push(Change::SetMangoHud(config)); - self - } - - /// Validates, persists, and publishes all queued changes. - /// - /// Changes are applied in call order, so a later change may supersede an - /// earlier one. Concurrent commits serialize and each starts from the - /// latest persisted state. If validation or persistence fails, no new - /// state snapshot is published. An empty edit is still persisted, but an - /// unchanged state does not notify [`Bottle::watch`]. - /// - /// # Errors - /// - /// Returns an error for a deleted bottle, a missing program removal, an - /// invalid environment variable, or a persistence failure. - pub async fn commit(self) -> Result<()> { - let BottleEdit { bottle, changes } = self; - bottle - .update(None, async move |state, _, _| { - for change in changes { - match change { - Change::Rename(name) => state.name = name, - Change::SetEnv(key, value) => { - if key.is_empty() || key.contains('=') || key.contains('\0') { - return Err(EnvironmentError::InvalidEnvironmentName(key).into()); - } - if value.contains('\0') { - return Err(EnvironmentError::InvalidEnvironmentValue(key).into()); - } - state.environment.env_vars.insert(key, value); + /// Metadata can change while running. Environment changes require an explicit + /// stop first. Storage and existing dependency order cannot be changed; new + /// dependencies may be appended. Addon selections must be downloaded. + /// Prefix effects are not yet rolled back as a batch if reconciliation or + /// persistence fails; no candidate configuration is published on failure. + pub fn edit( + &self, + callback: impl FnOnce(&mut BottleState) -> Result<()> + Send + 'static, + ) -> Operation<()> { + let bottle = self.clone(); + Operation::new(move |progress, cancellation| async move { + progress.send_replace(Some(Progress::new(Stage::Preparing))); + bottle + .update(Some(&cancellation), async |draft, cx, cached| { + let previous = draft.environment.clone(); + callback(draft)?; + if draft.id != bottle.id() { + return Err(BottleError::IdMismatch { + expected: bottle.id(), + actual: draft.id, } - Change::UnsetEnv(key) => { - if key.is_empty() || key.contains('=') || key.contains('\0') { - return Err(EnvironmentError::InvalidEnvironmentName(key).into()); - } - state.environment.env_vars.remove(&key); - } - Change::AddProgram(program) => { - state.programs.insert(program.id(), program); + .into()); + } + for (id, program) in &draft.programs { + if *id != program.id() { + return Err(BottleError::InvalidProgram( + "registration key must match the program ID".into(), + ) + .into()); } - Change::RemoveProgram(id) => { - state - .programs - .remove(&id) - .ok_or(BottleError::ProgramNotFound(id))?; + program.validate()?; + } + draft.environment.validate_requirements()?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + if draft.environment != previous { + let environment = cached.get_or_insert_with(|| { + crate::environment::Environment::new( + previous, + cx.directories().bottle(draft.id), + cx, + ) + }); + environment.ensure_stopped().await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); } - Change::SetGamescope(config) => { - state.environment.wrappers.gamescope = config + // Failed reconciliation must not retain its candidate configuration. + let mut environment = cached.take().expect("environment initialized"); + environment + .reconcile( + draft.environment.clone(), + &bottle.0.addons, + &progress, + &cancellation, + ) + .await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); } - Change::SetMangoHud(config) => state.environment.wrappers.mangohud = config, + draft.environment = environment.config.clone(); + *cached = Some(environment); } - } - Ok(()) - }) - .await + Ok(()) + }) + .await + }) } } diff --git a/src/bottle/mod.rs b/src/bottle/mod.rs index d590d8b..099d5fa 100644 --- a/src/bottle/mod.rs +++ b/src/bottle/mod.rs @@ -4,10 +4,9 @@ //! context. Its [`Bottle`] handles are live, cloneable references to shared //! state; [`BottleState`] values returned by those handles are immutable //! snapshots that do not change when the bottle is edited or deleted. -//! Configuration changes are queued with [`Bottle::edit`] and become visible -//! only after [`BottleEdit::commit`] persists them. -//! [`ProgramSpec`] construction validates launch definitions before -//! [`BottleEdit::add_program`] persists them. +//! [`Bottle::edit`] applies a callback to the latest state and publishes it after +//! validation, prefix reconciliation and persistence. [`ProgramSpec`] defines +//! programs registered through that callback. //! //! Bottle directories and their `bottle.toml` files are library-managed. //! Manager queries read an in-memory registry rather than rescanning or @@ -40,7 +39,6 @@ pub use crate::wrapper::{ gamescope::{Filter as GamescopeFilter, GamescopeConfig, Scaler as GamescopeScaler}, mangohud::MangoHudConfig, }; -pub use edit::BottleEdit; pub use error::BottleError; #[cfg(feature = "fvs")] pub use fvs_rs::{Commit as Snapshot, CommitSummary as SnapshotSummary}; diff --git a/src/bottle/software.rs b/src/bottle/software.rs index 77f7aad..f68f0e3 100644 --- a/src/bottle/software.rs +++ b/src/bottle/software.rs @@ -98,73 +98,80 @@ impl Bottle { } /// Stops WineBridge, wineserver and storage, then clears the cached environment. - /// Cleanup attempts every action and returns the first error. + /// Storage is released only after shutdown succeeds. pub async fn stop(&self) -> Result<()> { let mut environment = self.0.environment.lock().await; let state = self.state()?; Self::stop_state(&state, &self.0.cx, &mut environment).await } - /// Selects a downloaded component after validating the complete configuration. - /// Runtime changes stop the environment before changing prefix storage. + /// Selects a downloaded component in a stopped environment. + /// A runner requiring UMU selects the latest downloaded UMU if necessary. pub fn set_component(&self, id: Uuid) -> Operation<()> { - let bottle = self.clone(); let addons = self.0.addons.clone(); - Operation::new(move |progress, cancellation| async move { - bottle - .update_environment(&cancellation, async |environment| { - environment - .set_component(id, &addons, &progress, &cancellation) - .await - }) - .await + self.edit(move |state| { + let component = addons + .component(id) + .ok_or(crate::AddonError::NotFound(id))?; + let config = &mut state.environment; + if config + .component(component.slot()) + .is_some_and(|old| old.id() == id) + { + return Ok(()); + } + let needs_umu = component + .requirements() + .contains(&crate::Requirement::Slot(Slot::Umu)); + if needs_umu && config.umu().is_none() { + let umu = addons.latest_component(Slot::Umu).ok_or_else(|| { + crate::EnvironmentError::RequiresAddon { + required_by: Some(id), + requirements: vec![crate::Requirement::Slot(Slot::Umu)], + } + })?; + config + .components + .insert(Slot::Umu, crate::Addon::from(umu.as_ref())); + } + config + .components + .insert(component.slot(), crate::Addon::from(component.as_ref())); + if component.slot() == Slot::Runner && !needs_umu { + config.components.remove(&Slot::Umu); + } + Ok(()) }) } - /// Removes a component unless another selected addon requires it. + /// Removes a component from a stopped environment unless another addon requires it. pub fn remove_component(&self, slot: Slot) -> Operation<()> { - let bottle = self.clone(); - Operation::new(move |progress, cancellation| async move { - bottle - .update_environment(&cancellation, async |environment| { - environment - .remove_component(slot, &progress, &cancellation) - .await - }) - .await + self.edit(move |state| { + state + .environment + .components + .remove(&slot) + .ok_or(crate::EnvironmentError::ComponentNotInstalled(slot))?; + Ok(()) }) } - /// Permanently installs a downloaded dependency. Reinstalling its UUID is a no-op. + /// Permanently installs a downloaded dependency in a stopped environment. + /// Reinstalling its UUID is a no-op. pub fn install(&self, id: Uuid) -> Operation<()> { - let bottle = self.clone(); let addons = self.0.addons.clone(); - Operation::new(move |progress, cancellation| async move { - bottle - .update_environment(&cancellation, async |environment| { - environment - .install(id, &addons, &progress, &cancellation) - .await - }) - .await - }) - } - - async fn update_environment(&self, cancellation: &CancellationToken, work: F) -> Result<()> - where - F: for<'a> AsyncFnOnce(&'a mut Environment) -> Result<()>, - { - self.update(Some(cancellation), async |state, cx, cached| { - // A failed mutation must not leave its candidate configuration cached. - let mut environment = cached - .take() - .unwrap_or_else(|| Self::new_environment(state, &cx)); - work(&mut environment).await?; - state.environment = environment.config.clone(); - *cached = Some(environment); + self.edit(move |state| { + if state.environment.dependency(id).is_none() { + let dependency = addons + .dependency(id) + .ok_or(crate::AddonError::NotFound(id))?; + state + .environment + .dependencies + .push(crate::Addon::from(dependency.as_ref())); + } Ok(()) }) - .await } pub(super) async fn stop_state( diff --git a/src/bottle/state.rs b/src/bottle/state.rs index 316a1a2..5af4b6a 100644 --- a/src/bottle/state.rs +++ b/src/bottle/state.rs @@ -18,7 +18,7 @@ use tokio_stream::{StreamExt, wrappers::WatchStream}; use tokio_util::sync::CancellationToken; use uuid::Uuid; -use super::{edit::BottleEdit, error::BottleError}; +use super::error::BottleError; use crate::{ Context, EnvironmentConfig, addons::Addons, @@ -37,10 +37,10 @@ use crate::{ #[config(version = 1)] pub struct BottleState { pub(crate) id: Uuid, - pub(crate) name: String, - pub(crate) environment: EnvironmentConfig, + pub name: String, + pub environment: EnvironmentConfig, #[serde(default)] - pub(crate) programs: HashMap, + pub programs: HashMap, } impl BottleState { @@ -177,13 +177,6 @@ impl Bottle { .filter_map(|state| state) } - /// Starts a batch of configuration changes. - /// - /// No changes are made until [`BottleEdit::commit`] is awaited. - pub fn edit(&self) -> BottleEdit { - BottleEdit::new(self.clone()) - } - #[cfg(feature = "fvs")] pub(crate) fn ensure_exists(&self) -> Result<()> { if self.is_deleted() { @@ -237,14 +230,6 @@ impl Bottle { *environment = None; return Err(error); } - // Configuration edits apply at the next runtime start. Detaching leaves - // an already-running Wine instance available for sequential reconnect. - if environment - .as_ref() - .is_some_and(|runtime| runtime.config != draft.environment) - { - *environment = None; - } self.publish(draft); Ok(value) } @@ -303,24 +288,39 @@ pub struct ProgramSpec { } impl ProgramSpec { - /// Creates a program with a new UUID and default launch options. - pub fn new(name: impl Into, executable: impl Into) -> Result { - let name = name.into(); - let executable = executable.into(); - if name.trim().is_empty() { + pub(crate) fn validate(&self) -> Result<()> { + if self.name.trim().is_empty() { return Err(BottleError::InvalidProgram("name must not be blank".into()).into()); } - if executable.trim().is_empty() { + if self.executable.trim().is_empty() { return Err(BottleError::InvalidProgram("executable must not be blank".into()).into()); } - Ok(Self { + if self + .working_directory + .as_ref() + .is_some_and(|path| path.trim().is_empty()) + { + return Err( + BottleError::InvalidProgram("working directory must not be blank".into()).into(), + ); + } + Ok(()) + } + + /// Creates a program with a new UUID and default launch options. + pub fn new(name: impl Into, executable: impl Into) -> Result { + let name = name.into(); + let executable = executable.into(); + let program = Self { id: Uuid::new_v4(), name, executable, args: Vec::new(), working_directory: None, new_console: false, - }) + }; + program.validate()?; + Ok(program) } /// Replaces the Windows command-line fragments passed at launch. diff --git a/src/environment/error.rs b/src/environment/error.rs index 69fdb6f..da386e1 100644 --- a/src/environment/error.rs +++ b/src/environment/error.rs @@ -5,6 +5,12 @@ use uuid::Uuid; /// Failures in shared execution configuration and operations. #[derive(Debug, Error)] pub enum EnvironmentError { + #[error( + "stop the environment before changing its settings; if a previous operation failed, call stop() and retry" + )] + MustBeStopped, + #[error("invalid environment edit: {0}")] + InvalidEdit(&'static str), /// An environment variable name is empty or contains `=` or NUL. #[error( "invalid environment variable name {0:?}: names must be non-empty and contain neither '=' nor NUL" diff --git a/src/environment/mod.rs b/src/environment/mod.rs index 7381205..4c31e1c 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -42,6 +42,19 @@ impl Environment { } } + /// A retained runner or bridge, discovery file, or live mount requires an + /// explicit stop. A stale discovery file is ambiguous, not proof of shutdown. + pub(crate) async fn ensure_stopped(&self) -> Result<()> { + if self.runner.is_some() + || self.bridge.is_some() + || crate::utils::exists(&WineBridgeClient::port_file(&self.root.join("prefix"))).await? + || prefix::is_mounted(&self.config.storage, &self.root, &self.cx).await? + { + return Err(EnvironmentError::MustBeStopped.into()); + } + Ok(()) + } + async fn load_runner(&mut self) -> Result<()> { if self.runner.is_none() { self.runner = Some( @@ -126,7 +139,7 @@ impl Environment { } } - /// Attempts every cleanup action, retaining the first error for explicit retry. + /// Attempts bridge and runner shutdown; storage is released only after success. pub(crate) async fn stop(&mut self) -> Result<()> { let prefix = self.root.join("prefix"); let runner_loaded = self.load_runner().await; @@ -155,10 +168,15 @@ impl Environment { first_error.get_or_insert(error); } } - if let Err(error) = prefix::stop(&self.config.storage, &self.root, &self.cx).await { - first_error.get_or_insert(error); - } first_error.map_or(Ok(()), Err)?; + // A failed bridge can leave discovery behind. Only discard that evidence + // after Wine has stopped, while a Virgo prefix is still mounted. + match async_fs::remove_file(WineBridgeClient::port_file(&prefix)).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + prefix::stop(&self.config.storage, &self.root, &self.cx).await?; self.bridge = None; self.runner = None; Ok(()) diff --git a/src/environment/prefix/mod.rs b/src/environment/prefix/mod.rs index ab47cf2..2fd3dbd 100644 --- a/src/environment/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -98,6 +98,29 @@ pub(crate) async fn prepare(storage: &Storage, root: &Path, context: &Context) - } } +pub(crate) async fn is_mounted(storage: &Storage, root: &Path, context: &Context) -> Result { + let _ = (root, context); + match storage { + Storage::Standard => Ok(false), + #[cfg(feature = "fvs")] + Storage::Virgo { .. } => { + let mountpoint = root.join("prefix").display().to_string(); + Ok(context + .fvs() + .await? + .list_mounts() + .await? + .iter() + .any(|mount| { + mount + .spec + .as_ref() + .is_some_and(|spec| spec.mount_point == mountpoint) + })) + } + } +} + pub(crate) async fn stop(storage: &Storage, root: &Path, context: &Context) -> Result<()> { let _ = (root, context); match storage { diff --git a/src/environment/software.rs b/src/environment/software.rs index 037ec31..cd5eadf 100644 --- a/src/environment/software.rs +++ b/src/environment/software.rs @@ -1,242 +1,205 @@ -//! Addon operations on an owner's execution configuration and prefix data. +//! Reconcile edited execution settings with persistent prefix data. +use strum::IntoEnumIterator; use tokio::sync::watch; use tokio_util::sync::CancellationToken; -use uuid::Uuid; use super::{Environment, EnvironmentConfig, EnvironmentError, prefix}; use crate::{ - Addon, AddonError, Addons, Progress, Requirement, Slot, Stage, + Addon, AddonError, Addons, Progress, Slot, Stage, addons::{Artifact, InstallInputs, execute, replay_env_vars, uninstall}, error::{Error, Result}, }; impl Environment { - pub(crate) async fn set_component( + /// Called while the owner is coordinated and stopped. The owner publishes + /// only after all prefix work succeeds. Existing dependencies remain installed. + pub(crate) async fn reconcile( &mut self, - id: Uuid, + candidate: EnvironmentConfig, addons: &Addons, progress: &watch::Sender>, cancellation: &CancellationToken, ) -> Result<()> { - let component = addons.component(id).ok_or(AddonError::NotFound(id))?; - if self - .config - .component(component.slot()) - .is_some_and(|installed| installed.id() == id) + if candidate.storage != self.config.storage { + return Err(EnvironmentError::InvalidEdit( + "storage strategy and resolved layers are managed at creation and preparation", + ) + .into()); + } + if !candidate + .dependencies + .starts_with(&self.config.dependencies) { - return Ok(()); + return Err(EnvironmentError::InvalidEdit( + "installed dependencies cannot be removed, replaced or reordered", + ) + .into()); } - let mut candidate = self.config.clone(); - let needs_umu = component - .requirements() - .contains(&Requirement::Slot(Slot::Umu)); - if needs_umu && candidate.umu().is_none() { - let umu = addons.latest_component(Slot::Umu).ok_or_else(|| { - EnvironmentError::RequiresAddon { - required_by: Some(id), - requirements: vec![Requirement::Slot(Slot::Umu)], + + // Resolve every changed selection before any prefix work. Validation + // above this layer sees the final batch, never intermediate selections. + let mut removals = Vec::new(); + let mut installations = Vec::new(); + for slot in Slot::iter() { + let old = self.config.component(slot); + let new = candidate.component(slot); + if old == new { + continue; + } + if let Some(new) = new { + let downloaded = addons + .component(new.id()) + .ok_or(AddonError::NotFound(new.id()))?; + if Addon::from(downloaded.as_ref()) != *new { + return Err(EnvironmentError::InvalidEdit( + "component selection must match its downloaded release", + ) + .into()); } - })?; - candidate - .components - .insert(Slot::Umu, Addon::from(umu.as_ref())); - } - candidate - .components - .insert(component.slot(), Addon::from(component.as_ref())); - if component.slot() == Slot::Runner && !needs_umu { - candidate.components.remove(&Slot::Umu); + if !slot.is_runtime() { + installations.push(( + new.id(), + old.map(Addon::id), + vec![downloaded.artifact(self.cx.directories())], + )); + } + } else if let Some(old) = old.filter(|_| !slot.is_runtime()) { + removals.push((old.id(), vec![old.artifact(self.cx.directories())])); + } } - candidate.validate_requirements()?; - - if component.slot().is_runtime() { - progress.send_replace(Some(Progress::new(Stage::Stopping))); - self.stop().await?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); + for new in &candidate.dependencies[self.config.dependencies.len()..] { + if self.config.dependency(new.id()).is_some() + || candidate + .dependencies + .iter() + .filter(|addon| addon.id() == new.id()) + .count() + != 1 + { + return Err(EnvironmentError::InvalidEdit( + "a dependency may only be selected once", + ) + .into()); } - self.config = candidate; - if component.slot() == Slot::Runner { - progress.send_replace(Some(Progress::new(Stage::Rebuilding))); - let installed = self - .config - .components - .values() - .filter(|component| !component.slot().is_runtime()) - .map(Addon::id) - .chain(self.config.dependencies.iter().map(Addon::id)) - .collect::>(); - let runner = self - .config - .runner() - .load_runner(self.cx.directories(), self.config.umu()) - .await?; - let runner_key = self.config.runner().id().to_string(); - prefix::rebuild( - &mut self.config.storage, - runner.as_ref(), - &runner_key, - &installed, - &self.cx, + let downloaded = addons + .dependency(new.id()) + .ok_or(AddonError::NotFound(new.id()))?; + if Addon::from(downloaded.as_ref()) != *new { + return Err(EnvironmentError::InvalidEdit( + "dependency selection must match its downloaded release", ) - .await?; + .into()); } - return Ok(()); + let resources = downloaded + .artifacts() + .iter() + .map(|artifact| { + Artifact::new( + downloaded.path(self.cx.directories()).join(&artifact.path), + artifact.steps.clone(), + ) + }) + .collect(); + installations.push((new.id(), None, resources)); } - let replaced_id = self.config.component(component.slot()).map(Addon::id); - let resources = vec![component.artifact(self.cx.directories())]; - self.install_item( - candidate, - id, - replaced_id, - resources, - progress, - cancellation, - ) - .await - } - pub(crate) async fn remove_component( - &mut self, - slot: Slot, - progress: &watch::Sender>, - cancellation: &CancellationToken, - ) -> Result<()> { - let component = self - .config - .component(slot) - .cloned() - .ok_or(EnvironmentError::ComponentNotInstalled(slot))?; - let mut candidate = self.config.clone(); - candidate.components.remove(&slot); - candidate.validate_requirements()?; - let item_id = component.id(); - let resources = vec![component.artifact(self.cx.directories())]; - let winebridge = self.config.winebridge().path(self.cx.directories()); - self.stop().await?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); + let runner_changed = candidate.runner() != self.config.runner(); + if !runner_changed && removals.is_empty() && installations.is_empty() { + self.config = candidate; + return Ok(()); } - self.config = candidate; - let runner = self - .config + let runner = candidate .runner() - .load_runner(self.cx.directories(), self.config.umu()) + .load_runner(self.cx.directories(), candidate.umu()) .await?; + let winebridge = candidate.winebridge().path(self.cx.directories()); + let previous = std::mem::replace(&mut self.config, candidate); let env_vars = &mut self.config.env_vars; - prefix::uninstall( - &mut self.config.storage, - &self.root, - item_id, - async |prefix, restore_files| { - uninstall( - InstallInputs { - prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars, - }, - &resources, - restore_files, - item_id, - cancellation, - |_| { - progress.send_replace(Some(Progress::new(Stage::Removing))); - }, - ) - .await - }, - &self.cx, - cancellation, - |event| { - progress.send_replace(Some(event)); - }, - ) - .await - } - pub(crate) async fn install( - &mut self, - id: Uuid, - addons: &Addons, - progress: &watch::Sender>, - cancellation: &CancellationToken, - ) -> Result<()> { - let dependency = addons.dependency(id).ok_or(AddonError::NotFound(id))?; - if self.config.dependency(id).is_some() { - return Ok(()); + for (id, resources) in &removals { + prefix::uninstall( + &mut self.config.storage, + &self.root, + *id, + async |prefix, restore_files| { + uninstall( + InstallInputs { + prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars, + }, + resources, + restore_files, + *id, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Removing))); + }, + ) + .await + }, + &self.cx, + cancellation, + |event| { + progress.send_replace(Some(event)); + }, + ) + .await?; } - let mut candidate = self.config.clone(); - candidate - .dependencies - .push(Addon::from(dependency.as_ref())); - candidate.validate_requirements()?; - let resources = dependency - .artifacts() - .iter() - .map(|artifact| { - Artifact::new( - dependency.path(self.cx.directories()).join(&artifact.path), - artifact.steps.clone(), - ) - }) - .collect(); - self.install_item(candidate, id, None, resources, progress, cancellation) - .await - } - - /// The owner persists these changes only after the complete operation succeeds. - async fn install_item( - &mut self, - candidate: EnvironmentConfig, - item_id: Uuid, - replaced_id: Option, - resources: Vec, - progress: &watch::Sender>, - cancellation: &CancellationToken, - ) -> Result<()> { - self.stop().await?; - self.config = candidate; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); + if runner_changed { + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + progress.send_replace(Some(Progress::new(Stage::Rebuilding))); + let installed = Slot::iter() + .filter(|slot| !slot.is_runtime()) + .filter_map(|slot| previous.component(slot)) + .map(Addon::id) + .filter(|id| !removals.iter().any(|(removed, _)| removed == id)) + .chain(previous.dependencies.iter().map(Addon::id)) + .collect::>(); + prefix::rebuild( + &mut self.config.storage, + runner.as_ref(), + &self.config.components[&Slot::Runner].id().to_string(), + &installed, + &self.cx, + ) + .await?; } - let runner = self - .config - .runner() - .load_runner(self.cx.directories(), self.config.umu()) + for (id, replaced, resources) in installations { + prefix::install( + &mut self.config.storage, + &self.root, + id, + replaced, + async |prefix| { + execute( + InstallInputs { + prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars, + }, + &resources, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Configuring))); + }, + ) + .await + }, + &self.cx, + cancellation, + |event| { + progress.send_replace(Some(event)); + }, + ) .await?; - let winebridge = self.config.winebridge().path(self.cx.directories()); - let env_vars = &mut self.config.env_vars; - prefix::install( - &mut self.config.storage, - &self.root, - item_id, - replaced_id, - async |prefix| { - execute( - InstallInputs { - prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars, - }, - &resources, - cancellation, - |_| { - progress.send_replace(Some(Progress::new(Stage::Configuring))); - }, - ) - .await - }, - &self.cx, - cancellation, - |event| { - progress.send_replace(Some(event)); - }, - ) - .await?; - replay_env_vars(env_vars, &resources); + replay_env_vars(env_vars, &resources); + } Ok(()) } } diff --git a/src/lib.rs b/src/lib.rs index 3b90421..e229de6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,9 +18,8 @@ pub use addons::{ InstallerError, Requirement, Slot, }; pub use bottle::{ - Bottle, BottleEdit, BottleError, BottleManager, BottleState, DllOverride, DllOverrideMode, - GamescopeConfig, GamescopeFilter, GamescopeScaler, MangoHudConfig, Process, ProgramSpec, - RegistryHive, Wrappers, + Bottle, BottleError, BottleManager, BottleState, DllOverride, DllOverrideMode, GamescopeConfig, + GamescopeFilter, GamescopeScaler, MangoHudConfig, Process, ProgramSpec, RegistryHive, Wrappers, }; #[cfg(feature = "fvs")] pub use bottle::{Snapshot, SnapshotSummary}; diff --git a/src/runner/mod.rs b/src/runner/mod.rs index ab42589..d8471f1 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -121,7 +121,8 @@ pub(crate) async fn initialize_and_shutdown_prefix( } pub(crate) async fn shutdown_prefix(runner: &dyn Runner, prefix: &Path) -> Result<()> { - runner.wineserver(prefix, "-k").await + runner.wineserver(prefix, "-k").await?; + runner.wineserver(prefix, "-w").await } /// Classifies a component by its regular-file markers. diff --git a/src/runner/proton.rs b/src/runner/proton.rs index b98e43e..0717488 100644 --- a/src/runner/proton.rs +++ b/src/runner/proton.rs @@ -96,7 +96,9 @@ mod tests { .status() .await .unwrap(); - runner.wineserver(&prefix, "-k").await.unwrap(); + crate::runner::shutdown_prefix(&runner, &prefix) + .await + .unwrap(); assert!(matches!( runner.wineboot(&prefix, "--fail").await, Err(crate::error::Error::Runner(RunnerError::WinebootFailed(_))) @@ -119,6 +121,10 @@ mod tests { "{wineserver_environment}<{}><-k>\n", proton_path.join("files/bin/wineserver").display() ), + format!( + "{wineserver_environment}<{}><-w>\n", + proton_path.join("files/bin/wineserver").display() + ), format!("{environment}<--fail>\n"), format!( "{wineserver_environment}<{}><--fail>\n", diff --git a/src/utils/env_vars.rs b/src/utils/env_vars.rs index 2fa19ff..0e7fb3e 100644 --- a/src/utils/env_vars.rs +++ b/src/utils/env_vars.rs @@ -7,11 +7,11 @@ use serde::{Deserialize, Serialize}; pub struct EnvVars(HashMap); impl EnvVars { - pub(crate) fn insert(&mut self, name: T, value: T) -> Option { + pub fn insert(&mut self, name: T, value: T) -> Option { self.0.insert(name, value) } - pub(crate) fn remove(&mut self, name: &Q) -> Option + pub fn remove(&mut self, name: &Q) -> Option where T: Borrow, Q: Eq + Hash + ?Sized, diff --git a/src/winebridge.rs b/src/winebridge.rs index fc67e22..993dfcc 100644 --- a/src/winebridge.rs +++ b/src/winebridge.rs @@ -148,7 +148,7 @@ impl WineBridgeClient { })) } - fn port_file(prefix: &Path) -> PathBuf { + pub(crate) fn port_file(prefix: &Path) -> PathBuf { prefix.join("drive_c/windows/temp").join(PORT_FILE_NAME) } From 1e31c2e7d840ff968058d3b17a517e29f2d0a345 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Fri, 11 Sep 2026 00:04:08 +0530 Subject: [PATCH 05/24] fix(core): simplify live environments and independent shutdown --- README.md | 12 +- src/bottle/edit.rs | 34 ++-- src/bottle/software.rs | 99 +++++----- src/environment/error.rs | 4 +- src/environment/mod.rs | 167 ++++++---------- src/environment/prefix/mod.rs | 23 --- src/environment/software.rs | 352 +++++++++++++++++----------------- src/winebridge.rs | 10 +- 8 files changed, 308 insertions(+), 393 deletions(-) diff --git a/README.md b/README.md index ad16573..c2426ba 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,19 @@ The crate is centered around six types: Execution settings live in `BottleState::environment()` as an `EnvironmentConfig`. Each bottle retains a private environment on first runtime -use; clones share it. Registered programs use the bottle's settings. Use +use; clones share it. Creating the private environment resolves the runner, +prepares the prefix, and connects or starts WineBridge. Attach-only operations +connect without starting Wine or mounting storage. Its runner and bridge are +always present; configuration remains in the owner. Registered programs +use the bottle's settings. Use `Bottle::launch(ProgramSpec)` to run an unregistered executable and `Bottle::launch_program(uuid)` to run a registration. Both return `Operation` with the initial Windows process ID. Dropping the bottle only detaches; call -`stop()` to stop its runtime. +`stop()` to stop its runtime. Shutdown uses the saved runner and storage settings, +with or without a cached environment. It requests bridge shutdown when reachable, +then terminates and waits for wineserver before unmounting. Bridge discovery or +shutdown failure does not skip wineserver shutdown; the cached environment is +cleared only after shutdown and unmounting succeed. `Bottle::edit(|state| { /* changes */ Ok(()) })` returns an `Operation<()>`. The callback receives a draft of the latest state under the owner lock. Edit diff --git a/src/bottle/edit.rs b/src/bottle/edit.rs index ac51f31..4400019 100644 --- a/src/bottle/edit.rs +++ b/src/bottle/edit.rs @@ -50,32 +50,22 @@ impl Bottle { return Err(Error::Cancelled); } if draft.environment != previous { - let environment = cached.get_or_insert_with(|| { - crate::environment::Environment::new( - previous, - cx.directories().bottle(draft.id), - cx, - ) - }); - environment.ensure_stopped().await?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); + if cached.is_some() { + return Err(crate::EnvironmentError::MustBeStopped.into()); } - // Failed reconciliation must not retain its candidate configuration. - let mut environment = cached.take().expect("environment initialized"); - environment - .reconcile( - draft.environment.clone(), - &bottle.0.addons, - &progress, - &cancellation, - ) - .await?; + crate::environment::reconcile( + &previous, + &mut draft.environment, + &cx.directories().bottle(draft.id), + &cx, + &bottle.0.addons, + &progress, + &cancellation, + ) + .await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } - draft.environment = environment.config.clone(); - *cached = Some(environment); } Ok(()) }) diff --git a/src/bottle/software.rs b/src/bottle/software.rs index f68f0e3..5e56f2b 100644 --- a/src/bottle/software.rs +++ b/src/bottle/software.rs @@ -2,7 +2,6 @@ use std::ops::AsyncFnOnce; -use tokio_util::sync::CancellationToken; use uuid::Uuid; use super::{Bottle, BottleState, error::BottleError}; @@ -16,10 +15,8 @@ use crate::{ impl Bottle { /// Lists Wine DLL overrides, starting the environment if necessary. pub async fn dll_overrides(&self) -> Result> { - self.with_environment(None, async |_, environment| { - environment.dll_overrides().await - }) - .await + self.with_environment(async |environment| environment.dll_overrides().await) + .await } /// Sets a Wine DLL loading mode, starting the environment if necessary. @@ -28,8 +25,11 @@ impl Bottle { dll: impl Into, mode: DllOverrideMode, ) -> Result<()> { + if mode == DllOverrideMode::Unspecified { + return Err(crate::EnvironmentError::DllOverrideModeRequired.into()); + } let dll = dll.into(); - self.with_environment(None, async move |_, environment| { + self.with_environment(async move |environment| { environment.set_dll_override(dll, mode).await }) .await @@ -38,10 +38,8 @@ impl Bottle { /// Removes a Wine DLL override. Removing a missing override succeeds. pub async fn unset_dll_override(&self, dll: impl Into) -> Result<()> { let dll = dll.into(); - self.with_environment(None, async move |_, environment| { - environment.unset_dll_override(dll).await - }) - .await + self.with_environment(async move |environment| environment.unset_dll_override(dll).await) + .await } /// Launches the latest registration with this bottle's execution settings. @@ -70,31 +68,38 @@ impl Bottle { let bottle = self.clone(); Operation::new(move |progress, cancellation| async move { progress.send_replace(Some(Progress::new(Stage::Preparing))); - bottle - .with_environment(Some(&cancellation), async |state, environment| { - let program = resolve(state)?; - environment.launch(&program, &cancellation).await - }) + let mut cached = cancellation + .run_until_cancelled(bottle.0.environment.lock()) .await + .ok_or(Error::Cancelled)?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let state = bottle.state()?; + let program = resolve(&state)?; + let environment = Self::environment(&mut cached, &state, &bottle.0.cx).await?; + environment.launch_program(&program, &cancellation).await }) } /// Returns Windows processes, starting the environment if necessary. pub async fn processes(&self) -> Result> { - self.with_environment(None, async |_, environment| environment.processes().await) + self.with_environment(async |environment| environment.processes().await) .await } /// Terminates a registered program's UUID-keyed process group. /// This starts the environment if necessary and leaves it available afterward. pub async fn kill_program(&self, id: Uuid) -> Result<()> { - self.with_environment(None, async move |state, environment| { - if state.program(id).is_none() { - return Err(BottleError::ProgramNotFound(id).into()); - } - environment.kill(id).await - }) - .await + let mut cached = self.0.environment.lock().await; + let state = self.state()?; + if state.program(id).is_none() { + return Err(BottleError::ProgramNotFound(id).into()); + } + Self::environment(&mut cached, &state, &self.0.cx) + .await? + .kill(id) + .await } /// Stops WineBridge, wineserver and storage, then clears the cached environment. @@ -179,40 +184,36 @@ impl Bottle { cx: &Context, cached: &mut Option, ) -> Result<()> { - let environment = cached.get_or_insert_with(|| Self::new_environment(state, cx)); - environment.stop().await?; + Environment::stop(&state.environment, &cx.directories().bottle(state.id), cx).await?; *cached = None; Ok(()) } - fn new_environment(state: &BottleState, cx: &Context) -> Environment { - Environment::new( - state.environment.clone(), - cx.directories().bottle(state.id), - cx.clone(), - ) + async fn environment<'a>( + cached: &'a mut Option, + state: &BottleState, + cx: &Context, + ) -> Result<&'a Environment> { + if cached.is_none() { + *cached = Some( + Environment::attach_or_start( + &state.environment, + cx.directories().bottle(state.id), + cx.clone(), + ) + .await?, + ); + } + Ok(cached.as_ref().expect("environment initialized")) } - async fn with_environment( - &self, - cancellation: Option<&CancellationToken>, - work: F, - ) -> Result + async fn with_environment(&self, work: F) -> Result where - F: for<'a, 'b> AsyncFnOnce(&'a BottleState, &'b mut Environment) -> Result, + F: for<'a> AsyncFnOnce(&'a Environment) -> Result, { - let mut cached = match cancellation { - Some(cancellation) => cancellation - .run_until_cancelled(self.0.environment.lock()) - .await - .ok_or(Error::Cancelled)?, - None => self.0.environment.lock().await, - }; - if cancellation.is_some_and(CancellationToken::is_cancelled) { - return Err(Error::Cancelled); - } + let mut cached = self.0.environment.lock().await; let state = self.state()?; - let environment = cached.get_or_insert_with(|| Self::new_environment(&state, &self.0.cx)); - work(&state, environment).await + let environment = Self::environment(&mut cached, &state, &self.0.cx).await?; + work(environment).await } } diff --git a/src/environment/error.rs b/src/environment/error.rs index da386e1..dc5828e 100644 --- a/src/environment/error.rs +++ b/src/environment/error.rs @@ -5,9 +5,7 @@ use uuid::Uuid; /// Failures in shared execution configuration and operations. #[derive(Debug, Error)] pub enum EnvironmentError { - #[error( - "stop the environment before changing its settings; if a previous operation failed, call stop() and retry" - )] + #[error("stop the environment before changing its settings")] MustBeStopped, #[error("invalid environment edit: {0}")] InvalidEdit(&'static str), diff --git a/src/environment/mod.rs b/src/environment/mod.rs index 4c31e1c..e4c7964 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -5,7 +5,9 @@ mod error; pub(crate) mod prefix; mod software; -use std::path::PathBuf; +pub(crate) use software::reconcile; + +use std::path::{Path, PathBuf}; use tokio_util::sync::CancellationToken; @@ -21,80 +23,53 @@ pub use config::EnvironmentConfig; pub use error::EnvironmentError; pub use prefix::Storage; -/// A private owner-cached handle. Dropping it only releases local resources. +/// A private owner-cached live runtime. Construction connects WineBridge. +/// Dropping it only releases local resources. /// Owners serialize access and persist configuration, including storage metadata. pub(crate) struct Environment { - pub(crate) config: EnvironmentConfig, - root: PathBuf, - cx: Context, - runner: Option>, - bridge: Option, + // Retain the resolved runner with the live handle; shutdown uses saved settings. + #[allow(dead_code)] + runner: Box, + bridge: WineBridgeClient, } impl Environment { - pub(crate) fn new(config: EnvironmentConfig, root: PathBuf, cx: Context) -> Self { - Self { - config, - root, - cx, - runner: None, - bridge: None, - } - } - - /// A retained runner or bridge, discovery file, or live mount requires an - /// explicit stop. A stale discovery file is ambiguous, not proof of shutdown. - pub(crate) async fn ensure_stopped(&self) -> Result<()> { - if self.runner.is_some() - || self.bridge.is_some() - || crate::utils::exists(&WineBridgeClient::port_file(&self.root.join("prefix"))).await? - || prefix::is_mounted(&self.config.storage, &self.root, &self.cx).await? - { - return Err(EnvironmentError::MustBeStopped.into()); - } - Ok(()) - } - - async fn load_runner(&mut self) -> Result<()> { - if self.runner.is_none() { - self.runner = Some( - self.config - .runner() - .load_runner(self.cx.directories(), self.config.umu()) - .await?, - ); - } - Ok(()) - } - - async fn bridge(&mut self) -> Result<&WineBridgeClient> { - if self.bridge.is_none() { - self.load_runner().await?; - prefix::prepare(&self.config.storage, &self.root, &self.cx).await?; - let prefix = self.root.join("prefix"); - let command = self.config.wrappers.apply( - WineBridgeClient::command( - self.runner.as_deref().expect("runner loaded"), - &prefix, - self.config.winebridge().path(self.cx.directories()), - ) - .envs(self.config.env_vars.iter()), - ); - self.bridge = Some(WineBridgeClient::connect_or_spawn(&prefix, command).await?); + /// Connects to an existing runtime or prepares and starts one. + pub(crate) async fn attach_or_start( + config: &EnvironmentConfig, + root: PathBuf, + cx: Context, + ) -> Result { + let runner = config + .runner() + .load_runner(cx.directories(), config.umu()) + .await?; + let prefix = root.join("prefix"); + if let Some(bridge) = WineBridgeClient::try_connect(&prefix).await? { + return Ok(Self { runner, bridge }); } - Ok(self.bridge.as_ref().expect("bridge connected")) + prefix::prepare(&config.storage, &root, &cx).await?; + let command = config.wrappers.apply( + WineBridgeClient::command( + runner.as_ref(), + &prefix, + config.winebridge().path(cx.directories()), + ) + .envs(config.env_vars.iter()), + ); + let bridge = WineBridgeClient::connect_or_spawn(&prefix, command).await?; + Ok(Self { runner, bridge }) } - pub(crate) async fn launch( - &mut self, + pub(crate) async fn launch_program( + &self, program: &ProgramSpec, cancellation: &CancellationToken, ) -> Result { - let bridge = self.bridge().await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } - bridge + self.bridge .launch_process( program.id(), program.executable().to_owned(), @@ -105,80 +80,52 @@ impl Environment { .await } - pub(crate) async fn processes(&mut self) -> Result> { - self.bridge().await?.list_processes().await + pub(crate) async fn processes(&self) -> Result> { + self.bridge.list_processes().await } - pub(crate) async fn kill(&mut self, id: uuid::Uuid) -> Result<()> { - self.bridge().await?.kill_process(id).await + pub(crate) async fn kill(&self, id: uuid::Uuid) -> Result<()> { + self.bridge.kill_process(id).await } - pub(crate) async fn dll_overrides(&mut self) -> Result> { - match self.bridge().await?.list_dll_overrides().await { + pub(crate) async fn dll_overrides(&self) -> Result> { + match self.bridge.list_dll_overrides().await { Ok(overrides) => Ok(overrides), Err(Error::Status(status)) if status.code() == tonic::Code::NotFound => Ok(Vec::new()), Err(error) => Err(error), } } - pub(crate) async fn set_dll_override( - &mut self, - dll: String, - mode: DllOverrideMode, - ) -> Result<()> { - if mode == DllOverrideMode::Unspecified { - return Err(EnvironmentError::DllOverrideModeRequired.into()); - } - self.bridge().await?.set_dll_override(dll, mode).await + pub(crate) async fn set_dll_override(&self, dll: String, mode: DllOverrideMode) -> Result<()> { + self.bridge.set_dll_override(dll, mode).await } - pub(crate) async fn unset_dll_override(&mut self, dll: String) -> Result<()> { - match self.bridge().await?.delete_dll_override(dll).await { + pub(crate) async fn unset_dll_override(&self, dll: String) -> Result<()> { + match self.bridge.delete_dll_override(dll).await { Err(Error::Status(status)) if status.code() == tonic::Code::NotFound => Ok(()), result => result, } } - /// Attempts bridge and runner shutdown; storage is released only after success. - pub(crate) async fn stop(&mut self) -> Result<()> { - let prefix = self.root.join("prefix"); - let runner_loaded = self.load_runner().await; - let mut first_error = None; - // Discovery also supports stopping a runtime left by a previous client. + /// Stops Wine and releases storage without requiring a live handle or bridge. + pub(crate) async fn stop(config: &EnvironmentConfig, root: &Path, cx: &Context) -> Result<()> { + let runner = config + .runner() + .load_runner(cx.directories(), config.umu()) + .await?; + let prefix = root.join("prefix"); match WineBridgeClient::try_connect(&prefix).await { Ok(Some(bridge)) => { if let Err(error) = bridge.shutdown().await { - first_error.get_or_insert(error); + tracing::debug!(%error, "WineBridge shutdown failed; stopping wineserver"); } } Ok(None) => {} Err(error) => { - first_error.get_or_insert(error); + tracing::debug!(%error, "WineBridge discovery failed; stopping wineserver"); } } - match runner_loaded { - Ok(()) => { - if let Err(error) = - shutdown_prefix(self.runner.as_deref().expect("runner loaded"), &prefix).await - { - first_error.get_or_insert(error); - } - } - Err(error) => { - first_error.get_or_insert(error); - } - } - first_error.map_or(Ok(()), Err)?; - // A failed bridge can leave discovery behind. Only discard that evidence - // after Wine has stopped, while a Virgo prefix is still mounted. - match async_fs::remove_file(WineBridgeClient::port_file(&prefix)).await { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(error.into()), - } - prefix::stop(&self.config.storage, &self.root, &self.cx).await?; - self.bridge = None; - self.runner = None; - Ok(()) + shutdown_prefix(runner.as_ref(), &prefix).await?; + prefix::stop(&config.storage, root, cx).await } } diff --git a/src/environment/prefix/mod.rs b/src/environment/prefix/mod.rs index 2fd3dbd..ab47cf2 100644 --- a/src/environment/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -98,29 +98,6 @@ pub(crate) async fn prepare(storage: &Storage, root: &Path, context: &Context) - } } -pub(crate) async fn is_mounted(storage: &Storage, root: &Path, context: &Context) -> Result { - let _ = (root, context); - match storage { - Storage::Standard => Ok(false), - #[cfg(feature = "fvs")] - Storage::Virgo { .. } => { - let mountpoint = root.join("prefix").display().to_string(); - Ok(context - .fvs() - .await? - .list_mounts() - .await? - .iter() - .any(|mount| { - mount - .spec - .as_ref() - .is_some_and(|spec| spec.mount_point == mountpoint) - })) - } - } -} - pub(crate) async fn stop(storage: &Storage, root: &Path, context: &Context) -> Result<()> { let _ = (root, context); match storage { diff --git a/src/environment/software.rs b/src/environment/software.rs index cd5eadf..6666c13 100644 --- a/src/environment/software.rs +++ b/src/environment/software.rs @@ -1,205 +1,201 @@ //! Reconcile edited execution settings with persistent prefix data. +use std::path::Path; + use strum::IntoEnumIterator; use tokio::sync::watch; use tokio_util::sync::CancellationToken; -use super::{Environment, EnvironmentConfig, EnvironmentError, prefix}; +use super::{EnvironmentConfig, EnvironmentError, prefix}; use crate::{ - Addon, AddonError, Addons, Progress, Slot, Stage, + Addon, AddonError, Addons, Context, Progress, Slot, Stage, addons::{Artifact, InstallInputs, execute, replay_env_vars, uninstall}, error::{Error, Result}, }; -impl Environment { - /// Called while the owner is coordinated and stopped. The owner publishes - /// only after all prefix work succeeds. Existing dependencies remain installed. - pub(crate) async fn reconcile( - &mut self, - candidate: EnvironmentConfig, - addons: &Addons, - progress: &watch::Sender>, - cancellation: &CancellationToken, - ) -> Result<()> { - if candidate.storage != self.config.storage { - return Err(EnvironmentError::InvalidEdit( - "storage strategy and resolved layers are managed at creation and preparation", - ) - .into()); - } - if !candidate - .dependencies - .starts_with(&self.config.dependencies) - { - return Err(EnvironmentError::InvalidEdit( - "installed dependencies cannot be removed, replaced or reordered", - ) - .into()); - } +/// Called while the owner is coordinated and stopped. The owner publishes +/// only after all prefix work succeeds. Existing dependencies remain installed. +pub(crate) async fn reconcile( + previous: &EnvironmentConfig, + candidate: &mut EnvironmentConfig, + root: &Path, + cx: &Context, + addons: &Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, +) -> Result<()> { + if candidate.storage != previous.storage { + return Err(EnvironmentError::InvalidEdit( + "storage strategy and resolved layers are managed at creation and preparation", + ) + .into()); + } + if !candidate.dependencies.starts_with(&previous.dependencies) { + return Err(EnvironmentError::InvalidEdit( + "installed dependencies cannot be removed, replaced or reordered", + ) + .into()); + } - // Resolve every changed selection before any prefix work. Validation - // above this layer sees the final batch, never intermediate selections. - let mut removals = Vec::new(); - let mut installations = Vec::new(); - for slot in Slot::iter() { - let old = self.config.component(slot); - let new = candidate.component(slot); - if old == new { - continue; - } - if let Some(new) = new { - let downloaded = addons - .component(new.id()) - .ok_or(AddonError::NotFound(new.id()))?; - if Addon::from(downloaded.as_ref()) != *new { - return Err(EnvironmentError::InvalidEdit( - "component selection must match its downloaded release", - ) - .into()); - } - if !slot.is_runtime() { - installations.push(( - new.id(), - old.map(Addon::id), - vec![downloaded.artifact(self.cx.directories())], - )); - } - } else if let Some(old) = old.filter(|_| !slot.is_runtime()) { - removals.push((old.id(), vec![old.artifact(self.cx.directories())])); - } + // Resolve every changed selection before any prefix work. Validation + // above this layer sees the final batch, never intermediate selections. + let mut removals = Vec::new(); + let mut installations = Vec::new(); + for slot in Slot::iter() { + let old = previous.component(slot); + let new = candidate.component(slot); + if old == new { + continue; } - for new in &candidate.dependencies[self.config.dependencies.len()..] { - if self.config.dependency(new.id()).is_some() - || candidate - .dependencies - .iter() - .filter(|addon| addon.id() == new.id()) - .count() - != 1 - { - return Err(EnvironmentError::InvalidEdit( - "a dependency may only be selected once", - ) - .into()); - } + if let Some(new) = new { let downloaded = addons - .dependency(new.id()) + .component(new.id()) .ok_or(AddonError::NotFound(new.id()))?; if Addon::from(downloaded.as_ref()) != *new { return Err(EnvironmentError::InvalidEdit( - "dependency selection must match its downloaded release", + "component selection must match its downloaded release", ) .into()); } - let resources = downloaded - .artifacts() - .iter() - .map(|artifact| { - Artifact::new( - downloaded.path(self.cx.directories()).join(&artifact.path), - artifact.steps.clone(), - ) - }) - .collect(); - installations.push((new.id(), None, resources)); - } - - let runner_changed = candidate.runner() != self.config.runner(); - if !runner_changed && removals.is_empty() && installations.is_empty() { - self.config = candidate; - return Ok(()); + if !slot.is_runtime() { + installations.push(( + new.id(), + old.map(Addon::id), + vec![downloaded.artifact(cx.directories())], + )); + } + } else if let Some(old) = old.filter(|_| !slot.is_runtime()) { + removals.push((old.id(), vec![old.artifact(cx.directories())])); } - let runner = candidate - .runner() - .load_runner(self.cx.directories(), candidate.umu()) - .await?; - let winebridge = candidate.winebridge().path(self.cx.directories()); - let previous = std::mem::replace(&mut self.config, candidate); - let env_vars = &mut self.config.env_vars; - - for (id, resources) in &removals { - prefix::uninstall( - &mut self.config.storage, - &self.root, - *id, - async |prefix, restore_files| { - uninstall( - InstallInputs { - prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars, - }, - resources, - restore_files, - *id, - cancellation, - |_| { - progress.send_replace(Some(Progress::new(Stage::Removing))); - }, - ) - .await - }, - &self.cx, - cancellation, - |event| { - progress.send_replace(Some(event)); - }, - ) - .await?; + } + for new in &candidate.dependencies[previous.dependencies.len()..] { + if previous.dependency(new.id()).is_some() + || candidate + .dependencies + .iter() + .filter(|addon| addon.id() == new.id()) + .count() + != 1 + { + return Err( + EnvironmentError::InvalidEdit("a dependency may only be selected once").into(), + ); } - if runner_changed { - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - progress.send_replace(Some(Progress::new(Stage::Rebuilding))); - let installed = Slot::iter() - .filter(|slot| !slot.is_runtime()) - .filter_map(|slot| previous.component(slot)) - .map(Addon::id) - .filter(|id| !removals.iter().any(|(removed, _)| removed == id)) - .chain(previous.dependencies.iter().map(Addon::id)) - .collect::>(); - prefix::rebuild( - &mut self.config.storage, - runner.as_ref(), - &self.config.components[&Slot::Runner].id().to_string(), - &installed, - &self.cx, + let downloaded = addons + .dependency(new.id()) + .ok_or(AddonError::NotFound(new.id()))?; + if Addon::from(downloaded.as_ref()) != *new { + return Err(EnvironmentError::InvalidEdit( + "dependency selection must match its downloaded release", ) - .await?; + .into()); } - for (id, replaced, resources) in installations { - prefix::install( - &mut self.config.storage, - &self.root, - id, - replaced, - async |prefix| { - execute( - InstallInputs { - prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars, - }, - &resources, - cancellation, - |_| { - progress.send_replace(Some(Progress::new(Stage::Configuring))); - }, - ) - .await - }, - &self.cx, - cancellation, - |event| { - progress.send_replace(Some(event)); - }, - ) - .await?; - replay_env_vars(env_vars, &resources); + let resources = downloaded + .artifacts() + .iter() + .map(|artifact| { + Artifact::new( + downloaded.path(cx.directories()).join(&artifact.path), + artifact.steps.clone(), + ) + }) + .collect(); + installations.push((new.id(), None, resources)); + } + + let runner_changed = candidate.runner() != previous.runner(); + if !runner_changed && removals.is_empty() && installations.is_empty() { + return Ok(()); + } + let runner = candidate + .runner() + .load_runner(cx.directories(), candidate.umu()) + .await?; + let winebridge = candidate.winebridge().path(cx.directories()); + let env_vars = &mut candidate.env_vars; + + for (id, resources) in &removals { + prefix::uninstall( + &mut candidate.storage, + root, + *id, + async |prefix, restore_files| { + uninstall( + InstallInputs { + prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars, + }, + resources, + restore_files, + *id, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Removing))); + }, + ) + .await + }, + cx, + cancellation, + |event| { + progress.send_replace(Some(event)); + }, + ) + .await?; + } + if runner_changed { + if cancellation.is_cancelled() { + return Err(Error::Cancelled); } - Ok(()) + progress.send_replace(Some(Progress::new(Stage::Rebuilding))); + let installed = Slot::iter() + .filter(|slot| !slot.is_runtime()) + .filter_map(|slot| previous.component(slot)) + .map(Addon::id) + .filter(|id| !removals.iter().any(|(removed, _)| removed == id)) + .chain(previous.dependencies.iter().map(Addon::id)) + .collect::>(); + prefix::rebuild( + &mut candidate.storage, + runner.as_ref(), + &candidate.components[&Slot::Runner].id().to_string(), + &installed, + cx, + ) + .await?; + } + for (id, replaced, resources) in installations { + prefix::install( + &mut candidate.storage, + root, + id, + replaced, + async |prefix| { + execute( + InstallInputs { + prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars, + }, + &resources, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Configuring))); + }, + ) + .await + }, + cx, + cancellation, + |event| { + progress.send_replace(Some(event)); + }, + ) + .await?; + replay_env_vars(env_vars, &resources); } + Ok(()) } diff --git a/src/winebridge.rs b/src/winebridge.rs index 993dfcc..14aee8b 100644 --- a/src/winebridge.rs +++ b/src/winebridge.rs @@ -148,7 +148,7 @@ impl WineBridgeClient { })) } - pub(crate) fn port_file(prefix: &Path) -> PathBuf { + fn port_file(prefix: &Path) -> PathBuf { prefix.join("drive_c/windows/temp").join(PORT_FILE_NAME) } @@ -650,19 +650,17 @@ impl WineBridgeClient { /// Requests the managed WineBridge server to shut down. /// - /// This consumes the wrapper so callers cannot issue more RPCs after shutdown. + /// The owner releases the connection after its complete shutdown succeeds. /// /// # Errors /// /// Returns an error if the shutdown RPC fails. - pub async fn shutdown(self) -> Result<()> { + pub async fn shutdown(&self) -> Result<()> { let mut client = self.client.clone(); client.shutdown(()).await?; drop(client); - let port_file = self.port_file.clone(); - drop(self); for _ in 0..50 { - if !exists(&port_file).await? { + if !exists(&self.port_file).await? { return Ok(()); } Timer::after(Duration::from_millis(100)).await; From 7a7edecafb2d88434b521fcdad6402a661ea4fd4 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Fri, 11 Sep 2026 02:25:53 +0530 Subject: [PATCH 06/24] fix(core): centralize environment lifecycle --- README.md | 37 ++-- src/addons/installer/engine.rs | 139 ++++++-------- src/addons/installer/mod.rs | 10 +- src/bottle/edit.rs | 100 +++++----- src/bottle/manager.rs | 30 +-- src/bottle/snapshot.rs | 14 +- src/bottle/software.rs | 82 ++++----- src/bottle/state.rs | 56 +----- src/bottle/tests.rs | 19 +- src/environment/error.rs | 9 + src/environment/mod.rs | 84 +++++---- src/environment/prefix/mod.rs | 252 +++----------------------- src/environment/prefix/standard.rs | 27 +-- src/environment/prefix/virgo/cache.rs | 124 +++++++------ src/environment/prefix/virgo/mod.rs | 164 ++++++++--------- src/environment/software.rs | 169 ++++++++++++----- src/runner/mod.rs | 19 -- src/runner/proton.rs | 5 +- src/winebridge.rs | 66 +++++-- 19 files changed, 631 insertions(+), 775 deletions(-) diff --git a/README.md b/README.md index c2426ba..6ccd0bc 100644 --- a/README.md +++ b/README.md @@ -33,21 +33,23 @@ The crate is centered around six types: - `Operation` represents long-running work with progress and cooperative cancellation. -Execution settings live in `BottleState::environment()` as an -`EnvironmentConfig`. Each bottle retains a private environment on first runtime -use; clones share it. Creating the private environment resolves the runner, -prepares the prefix, and connects or starts WineBridge. Attach-only operations -connect without starting Wine or mounting storage. Its runner and bridge are -always present; configuration remains in the owner. Registered programs -use the bottle's settings. Use -`Bottle::launch(ProgramSpec)` to run an unregistered executable and -`Bottle::launch_program(uuid)` to run a registration. Both return `Operation` -with the initial Windows process ID. Dropping the bottle only detaches; call -`stop()` to stop its runtime. Shutdown uses the saved runner and storage settings, -with or without a cached environment. It requests bridge shutdown when reachable, -then terminates and waits for wineserver before unmounting. Bridge discovery or -shutdown failure does not skip wineserver shutdown; the cached environment is -cleared only after shutdown and unmounting succeed. +Execution settings live in `BottleState::environment()` as an `EnvironmentConfig`. +Cloned bottle handles share an operation mutex. Each runtime call attaches through +a temporary environment connection; registered programs use the bottle's settings. `Bottle::launch(ProgramSpec)` runs an +unregistered executable; `Bottle::launch_program(uuid)` runs a registration. +Both return `Operation` with the initial Windows process ID. + +Process inspection and group kill attach to an existing runtime without starting +Wine or inspecting FVS mounts. Startup still checks existing Virgo mounts against +saved layers and the private upper when preparing storage. Dropping handles leaves +Wine running. Explicit `stop()` waits +for wineserver before unmounting, even when WineBridge cannot be reached. +Unreachable discovery and mismatched mounts require `stop()` before retrying. + +Initialization and recipes run without game wrappers. Cancellation finishes cleanup +before returning; failed shutdown retains storage. Temporary cache failures can +require manual cleanup of the reported prefix. Automatic recovery and concurrent +independent clients are deferred. `Bottle::edit(|state| { /* changes */ Ok(()) })` returns an `Operation<()>`. The callback receives a draft of the latest state under the owner lock. Edit @@ -57,6 +59,11 @@ settings, including through `set_component`, `remove_component`, or `install`. Storage is fixed at creation; existing dependencies remain in installation order. New dependency selections can be appended. Prefix changes run before publication; batch rollback of those effects remains part of the later composition work. +Settings-only edits save the draft without preparing or mutating the prefix. + +Standard no-FVS operation, pinned Soda builds, and managed registry-baseline +composition remain separate later steps. Existing per-addon layer and checkpoint +behavior remains in place for now. Bottle configuration requires execution settings under `environment`, with resolved FVS layers retained inside `environment.storage` for Virgo. Old diff --git a/src/addons/installer/engine.rs b/src/addons/installer/engine.rs index facd5f8..dd4ba9f 100644 --- a/src/addons/installer/engine.rs +++ b/src/addons/installer/engine.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use crate::{ addons::InstallerError, error::{Error, Result, ResultExt}, - runner::{Command, Spawnable, shutdown_prefix}, + runner::{Command, Runner, Spawnable}, utils::{archive, env_vars::EnvVars, exists}, winebridge::WineBridgeClient, }; @@ -22,14 +22,8 @@ use super::{Artifact, InstallInputs, InstallStep}; /// /// Cancellation is checked before the first step, after every step, while waiting for child /// processes, between per-DLL operations, and during extraction. Cancellation attempts to kill -/// and reap a running child; a kill failure is returned. Before returning, this function always -/// attempts to stop WineBridge and then the prefix runner. -/// -/// # Errors -/// -/// Returns the recipe error in preference to cleanup errors. When the recipe succeeds, a -/// WineBridge shutdown error takes precedence over a runner shutdown error, although both -/// shutdowns are attempted. +/// and reap a running child; a kill failure is returned. The enclosing prefix scope stops Wine +/// before diffing, unmounting, or restoring storage. pub(crate) async fn execute( inputs: InstallInputs<'_>, resources: &[Artifact], @@ -42,43 +36,34 @@ pub(crate) async fn execute( winebridge, env_vars, } = inputs; - let result = async { - check_cancellation(cancellation)?; - for resource in resources { - for step in &resource.steps { - on_step(step); - execute_step( - InstallInputs { - prefix, - runner, - winebridge, - env_vars: &mut *env_vars, - }, - resource, - step, - cancellation, - ) - .await?; - check_cancellation(cancellation)?; - } + check_cancellation(cancellation)?; + for resource in resources { + for step in &resource.steps { + on_step(step); + execute_step( + InstallInputs { + prefix, + runner, + winebridge, + env_vars: &mut *env_vars, + }, + resource, + step, + cancellation, + ) + .await?; + check_cancellation(cancellation)?; } - Ok::<_, Error>(()) } - .await; - - let bridge_stopped = shutdown_bridge(prefix).await; - let runner_stopped = shutdown_prefix(runner, prefix).await; - result?; - bridge_stopped?; - runner_stopped + Ok(()) } /// Attempts to undo a recipe in reverse resource and step order. /// /// File copies are restored or removed only when `restore_files` is true. Environment entries are /// removed and DLL overrides are deleted. Other step kinds have no inverse and are skipped with a -/// warning. File, bridge, override, and final process-cleanup failures are also logged and ignored; -/// cancellation and other control-flow errors are returned. +/// warning. File, bridge and override failures are logged and ignored; cancellation is returned. +/// The enclosing prefix scope owns Wine shutdown. pub(crate) async fn uninstall( inputs: InstallInputs<'_>, resources: &[Artifact], @@ -94,34 +79,27 @@ pub(crate) async fn uninstall( env_vars, } = inputs; - let result = async { - check_cancellation(cancellation)?; - for resource in resources.iter().rev() { - for step in resource.steps.iter().rev() { - on_step(step); - uninstall_step( - InstallInputs { - prefix, - runner, - winebridge, - env_vars: &mut *env_vars, - }, - step, - restore_files, - item_id, - cancellation, - ) - .await?; - check_cancellation(cancellation)?; - } + check_cancellation(cancellation)?; + for resource in resources.iter().rev() { + for step in resource.steps.iter().rev() { + on_step(step); + uninstall_step( + InstallInputs { + prefix, + runner, + winebridge, + env_vars: &mut *env_vars, + }, + step, + restore_files, + item_id, + cancellation, + ) + .await?; + check_cancellation(cancellation)?; } - Ok(()) } - .await; - - shutdown_bridge(prefix).await.log_warn(); - shutdown_prefix(runner, prefix).await.log_warn(); - result + Ok(()) } /// Ensures environment changes are applied when prefix storage reuses an existing addon layer. @@ -138,6 +116,16 @@ pub(crate) fn replay_env_vars(env_vars: &mut EnvVars, resources: &[Artifact]) { } } +async fn maintenance_bridge( + runner: &dyn Runner, + prefix: &Path, + executable: &Path, + env_vars: &EnvVars, +) -> Result { + let command = WineBridgeClient::command(runner, prefix, executable).envs(env_vars.iter()); + WineBridgeClient::connect_or_spawn(prefix, command).await +} + async fn execute_step( inputs: InstallInputs<'_>, resource: &Artifact, @@ -199,18 +187,14 @@ async fn execute_step( name, value, } => { - let command = - WineBridgeClient::command(runner, prefix, winebridge).envs(env_vars.iter()); - let bridge = WineBridgeClient::connect_or_spawn(prefix, command).await?; + let bridge = maintenance_bridge(runner, prefix, winebridge, env_vars).await?; check_cancellation(cancellation)?; bridge .set_registry_value(*hive, key.clone(), name.clone(), value.clone()) .await?; } InstallStep::SetDllOverrides { dlls, mode } => { - let command = - WineBridgeClient::command(runner, prefix, winebridge).envs(env_vars.iter()); - let bridge = WineBridgeClient::connect_or_spawn(prefix, command).await?; + let bridge = maintenance_bridge(runner, prefix, winebridge, env_vars).await?; for dll in dlls { check_cancellation(cancellation)?; bridge.set_dll_override(dll.clone(), *mode).await?; @@ -218,7 +202,7 @@ async fn execute_step( } InstallStep::SetEnvironment { name, value } => { env_vars.insert(name.clone(), value.clone()); - shutdown_bridge(prefix).await?; + WineBridgeClient::shutdown_existing(prefix).await?; } } Ok(()) @@ -246,12 +230,10 @@ async fn uninstall_step( InstallStep::Copy { .. } => {} InstallStep::SetEnvironment { name, .. } => { env_vars.remove(name); - shutdown_bridge(prefix).await.log_warn(); + WineBridgeClient::shutdown_existing(prefix).await.log_warn(); } InstallStep::SetDllOverrides { dlls, .. } => { - let command = - WineBridgeClient::command(runner, prefix, winebridge).envs(env_vars.iter()); - let bridge = match WineBridgeClient::connect_or_spawn(prefix, command).await { + let bridge = match maintenance_bridge(runner, prefix, winebridge, env_vars).await { Ok(bridge) => bridge, Err(error) => { tracing::warn!(%error); @@ -313,13 +295,6 @@ fn is_not_found(error: &Error) -> bool { matches!(error, Error::Status(status) if status.code() == tonic::Code::NotFound) } -async fn shutdown_bridge(prefix: &Path) -> Result<()> { - if let Some(bridge) = WineBridgeClient::try_connect(prefix).await? { - bridge.shutdown().await?; - } - Ok(()) -} - /// Copies a file into a prefix, preserving the first displaced regular file as a backup. /// /// The backup is stored alongside the destination with `.bak` appended. An existing backup is diff --git a/src/addons/installer/mod.rs b/src/addons/installer/mod.rs index 434349b..382309e 100644 --- a/src/addons/installer/mod.rs +++ b/src/addons/installer/mod.rs @@ -27,8 +27,8 @@ //! Cancellation is cooperative. It is checked between steps and during //! supported long-running work. Running child processes are killed and reaped //! when possible; WineBridge calls already in flight are not interrupted. -//! Installation always attempts to stop WineBridge and the prefix runner before -//! returning. +//! The enclosing prefix scope stops WineBridge and the prefix runner before +//! releasing storage. //! //! # Path handling //! @@ -145,13 +145,13 @@ pub(crate) enum InstallStep { SetEnvironment { name: String, value: String }, } -/// Bottle-specific services and mutable state used while applying a recipe. +/// Execution inputs for a recipe in an owner prefix or shared build. pub(crate) struct InstallInputs<'a> { /// The prepared Wine prefix receiving recipe changes. pub(crate) prefix: &'a Path, - /// The runner used for Windows processes and prefix shutdown. + /// The runner used for Windows processes. The execution workflow owns shutdown. pub(crate) runner: &'a dyn Runner, - /// The WineBridge executable selected by the bottle. + /// The WineBridge executable selected by the execution workflow. pub(crate) winebridge: &'a Path, /// The environment updated by `SetEnvironment` steps and passed to processes. pub(crate) env_vars: &'a mut EnvVars, diff --git a/src/bottle/edit.rs b/src/bottle/edit.rs index 4400019..1883441 100644 --- a/src/bottle/edit.rs +++ b/src/bottle/edit.rs @@ -25,51 +25,63 @@ impl Bottle { let bottle = self.clone(); Operation::new(move |progress, cancellation| async move { progress.send_replace(Some(Progress::new(Stage::Preparing))); - bottle - .update(Some(&cancellation), async |draft, cx, cached| { - let previous = draft.environment.clone(); - callback(draft)?; - if draft.id != bottle.id() { - return Err(BottleError::IdMismatch { - expected: bottle.id(), - actual: draft.id, - } - .into()); - } - for (id, program) in &draft.programs { - if *id != program.id() { - return Err(BottleError::InvalidProgram( - "registration key must match the program ID".into(), - ) - .into()); - } - program.validate()?; - } - draft.environment.validate_requirements()?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - if draft.environment != previous { - if cached.is_some() { - return Err(crate::EnvironmentError::MustBeStopped.into()); - } - crate::environment::reconcile( - &previous, - &mut draft.environment, - &cx.directories().bottle(draft.id), - &cx, - &bottle.0.addons, - &progress, - &cancellation, - ) - .await?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - } - Ok(()) - }) + let cx = &bottle.0.cx; + let _control = cancellation + .run_until_cancelled(bottle.0.control.lock()) .await + .ok_or(Error::Cancelled)?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let previous = bottle.state()?; + let mut draft = previous.as_ref().clone(); + callback(&mut draft)?; + if draft.id != bottle.id() { + return Err(BottleError::IdMismatch { + expected: bottle.id(), + actual: draft.id, + } + .into()); + } + for (id, program) in &draft.programs { + if *id != program.id() { + return Err(BottleError::InvalidProgram( + "registration key must match the program ID".into(), + ) + .into()); + } + program.validate()?; + } + draft.environment.validate_requirements()?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + if draft.environment != previous.environment { + if crate::environment::Environment::try_attach( + &cx.directories().bottle(previous.id), + ) + .await? + .is_some() + { + return Err(crate::EnvironmentError::MustBeStopped.into()); + } + crate::environment::reconcile( + &previous.environment, + &mut draft.environment, + &cx.directories().bottle(draft.id), + cx, + &bottle.0.addons, + &progress, + &cancellation, + ) + .await?; + } + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + Self::save_state(&draft, cx).await?; + bottle.publish(draft); + Ok(()) }) } } diff --git a/src/bottle/manager.rs b/src/bottle/manager.rs index 387d3c4..9498dcc 100644 --- a/src/bottle/manager.rs +++ b/src/bottle/manager.rs @@ -207,18 +207,22 @@ impl BottleManager { .await?; let id = Uuid::new_v4(); let bottle_path = cx.directories().bottle(id); - fs::create_dir_all(&bottle_path).await?; + progress.send_replace(Some(Progress::new(Stage::CreatingPrefix))); + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + fs::create_dir_all(&bottle_path).await?; + // Creation may retain live storage on failure; keep it outside the removal path. + prefix::create( + &mut storage, + &bottle_path, + loaded_runner.as_ref(), + &runner_component.id().to_string(), + &cx, + ) + .await?; let result = async { - progress.send_replace(Some(Progress::new(Stage::CreatingPrefix))); - prefix::create( - &mut storage, - &bottle_path, - loaded_runner.as_ref(), - &runner_component.id().to_string(), - &cx, - ) - .await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } @@ -283,8 +287,8 @@ impl BottleManager { let manager = self.clone(); Operation::new(move |progress, cancellation| async move { let bottle = manager.open(id).await?; - let mut environment = cancellation - .run_until_cancelled(bottle.0.environment.lock()) + let _control = cancellation + .run_until_cancelled(bottle.0.control.lock()) .await .ok_or(Error::Cancelled)?; if cancellation.is_cancelled() { @@ -292,7 +296,7 @@ impl BottleManager { } let state = bottle.state()?; progress.send_replace(Some(Progress::new(Stage::Stopping))); - Bottle::stop_state(&state, &bottle.0.cx, &mut environment).await?; + Bottle::stop_state(&state, &bottle.0.cx).await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } diff --git a/src/bottle/snapshot.rs b/src/bottle/snapshot.rs index 5bb9a89..c57d575 100644 --- a/src/bottle/snapshot.rs +++ b/src/bottle/snapshot.rs @@ -40,8 +40,8 @@ impl Bottle { let cx = self.0.cx.clone(); let message = message.into(); Operation::new(move |progress, cancellation| async move { - let mut environment = cancellation - .run_until_cancelled(bottle.0.environment.lock()) + let _control = cancellation + .run_until_cancelled(bottle.0.control.lock()) .await .ok_or(Error::Cancelled)?; if cancellation.is_cancelled() { @@ -49,7 +49,7 @@ impl Bottle { } let state = bottle.state()?; progress.send_replace(Some(Progress::new(Stage::Stopping))); - Bottle::stop_state(&state, &cx, &mut environment).await?; + Bottle::stop_state(&state, &cx).await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } @@ -77,7 +77,7 @@ impl Bottle { /// Returns an error if the bottle was deleted, the FVS service is /// unavailable, or its snapshot history cannot be read. pub async fn snapshots(&self) -> Result> { - let _read = self.0.environment.lock().await; + let _read = self.0.control.lock().await; self.ensure_exists()?; let repository = self.snapshot_repository(); Ok(self @@ -122,8 +122,8 @@ impl Bottle { let cx = self.0.cx.clone(); let state_id_or_prefix = state_id_or_prefix.to_owned(); Operation::new(move |progress, cancellation| async move { - let mut environment = cancellation - .run_until_cancelled(bottle.0.environment.lock()) + let _control = cancellation + .run_until_cancelled(bottle.0.control.lock()) .await .ok_or(Error::Cancelled)?; if cancellation.is_cancelled() { @@ -131,7 +131,7 @@ impl Bottle { } let state = bottle.state()?; progress.send_replace(Some(Progress::new(Stage::Stopping))); - Bottle::stop_state(&state, &cx, &mut environment).await?; + Bottle::stop_state(&state, &cx).await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } diff --git a/src/bottle/software.rs b/src/bottle/software.rs index 5e56f2b..bfa5da7 100644 --- a/src/bottle/software.rs +++ b/src/bottle/software.rs @@ -1,4 +1,4 @@ -//! Public bottle operations coordinated around a private cached environment. +//! Public bottle operations serialized around temporary environment connections. use std::ops::AsyncFnOnce; @@ -68,8 +68,8 @@ impl Bottle { let bottle = self.clone(); Operation::new(move |progress, cancellation| async move { progress.send_replace(Some(Progress::new(Stage::Preparing))); - let mut cached = cancellation - .run_until_cancelled(bottle.0.environment.lock()) + let _control = cancellation + .run_until_cancelled(bottle.0.control.lock()) .await .ok_or(Error::Cancelled)?; if cancellation.is_cancelled() { @@ -77,37 +77,48 @@ impl Bottle { } let state = bottle.state()?; let program = resolve(&state)?; - let environment = Self::environment(&mut cached, &state, &bottle.0.cx).await?; + let environment = Environment::attach_or_start( + &state.environment, + bottle.0.cx.directories().bottle(state.id), + bottle.0.cx.clone(), + ) + .await?; environment.launch_program(&program, &cancellation).await }) } - /// Returns Windows processes, starting the environment if necessary. + /// Returns Windows processes without starting a stopped environment. pub async fn processes(&self) -> Result> { - self.with_environment(async |environment| environment.processes().await) - .await + let _control = self.0.control.lock().await; + let state = self.state()?; + match Environment::try_attach(&self.0.cx.directories().bottle(state.id)).await? { + Some(environment) => environment.processes().await, + None => Ok(Vec::new()), + } } /// Terminates a registered program's UUID-keyed process group. - /// This starts the environment if necessary and leaves it available afterward. + /// A stopped environment is left stopped; a running environment remains available. pub async fn kill_program(&self, id: Uuid) -> Result<()> { - let mut cached = self.0.environment.lock().await; + let _control = self.0.control.lock().await; let state = self.state()?; if state.program(id).is_none() { return Err(BottleError::ProgramNotFound(id).into()); } - Self::environment(&mut cached, &state, &self.0.cx) - .await? - .kill(id) - .await + if let Some(environment) = + Environment::try_attach(&self.0.cx.directories().bottle(state.id)).await? + { + environment.kill(id).await?; + } + Ok(()) } - /// Stops WineBridge, wineserver and storage, then clears the cached environment. + /// Stops WineBridge, wineserver and storage without requiring attachment. /// Storage is released only after shutdown succeeds. pub async fn stop(&self) -> Result<()> { - let mut environment = self.0.environment.lock().await; + let _control = self.0.control.lock().await; let state = self.state()?; - Self::stop_state(&state, &self.0.cx, &mut environment).await + Self::stop_state(&state, &self.0.cx).await } /// Selects a downloaded component in a stopped environment. @@ -179,41 +190,22 @@ impl Bottle { }) } - pub(super) async fn stop_state( - state: &BottleState, - cx: &Context, - cached: &mut Option, - ) -> Result<()> { - Environment::stop(&state.environment, &cx.directories().bottle(state.id), cx).await?; - *cached = None; - Ok(()) - } - - async fn environment<'a>( - cached: &'a mut Option, - state: &BottleState, - cx: &Context, - ) -> Result<&'a Environment> { - if cached.is_none() { - *cached = Some( - Environment::attach_or_start( - &state.environment, - cx.directories().bottle(state.id), - cx.clone(), - ) - .await?, - ); - } - Ok(cached.as_ref().expect("environment initialized")) + pub(super) async fn stop_state(state: &BottleState, cx: &Context) -> Result<()> { + Environment::stop(&state.environment, &cx.directories().bottle(state.id), cx).await } async fn with_environment(&self, work: F) -> Result where F: for<'a> AsyncFnOnce(&'a Environment) -> Result, { - let mut cached = self.0.environment.lock().await; + let _control = self.0.control.lock().await; let state = self.state()?; - let environment = Self::environment(&mut cached, &state, &self.0.cx).await?; - work(environment).await + let environment = Environment::attach_or_start( + &state.environment, + self.0.cx.directories().bottle(state.id), + self.0.cx.clone(), + ) + .await?; + work(&environment).await } } diff --git a/src/bottle/state.rs b/src/bottle/state.rs index 5af4b6a..ba52f17 100644 --- a/src/bottle/state.rs +++ b/src/bottle/state.rs @@ -3,7 +3,6 @@ use std::{ collections::HashMap, hash::{Hash, Hasher}, - ops::AsyncFnOnce, sync::Arc, }; @@ -15,16 +14,10 @@ use next_config::Config; use serde::{Deserialize, Serialize}; use tokio::sync::{Mutex, watch}; use tokio_stream::{StreamExt, wrappers::WatchStream}; -use tokio_util::sync::CancellationToken; use uuid::Uuid; use super::error::BottleError; -use crate::{ - Context, EnvironmentConfig, - addons::Addons, - environment::Environment, - error::{Error, Result}, -}; +use crate::{Context, EnvironmentConfig, addons::Addons, error::Result}; /// An immutable snapshot of a bottle's published configuration. /// @@ -76,8 +69,8 @@ impl BottleState { pub(crate) struct BottleInner { /// Latest state; `None` is the tombstone published when the bottle is deleted. pub(crate) published: watch::Sender>>, - /// Serializes control operations and retains the lazily created runtime. - pub(crate) environment: Mutex>, + /// Serializes control operations across cloned handles. + pub(crate) control: Mutex<()>, /// Retained after deletion so stale handles report which bottle was deleted. pub(crate) id: Uuid, /// Shared services and storage locations scoped to the owning manager. @@ -94,7 +87,7 @@ pub(crate) struct BottleInner { /// Hashing identifies the shared live handle and remains stable across state /// publications and deletion. /// -/// Runtime operations share a lazily created private environment. Control calls +/// Runtime operations attach to WineBridge for each control call. Calls /// serialize within this core instance; the lock is released after launch, not /// when the guest process exits. Dropping handles does not stop Wine. #[derive(Clone)] @@ -137,7 +130,7 @@ impl Bottle { Ok(Self(Arc::new(BottleInner { id, published, - environment: Mutex::new(None), + control: Mutex::new(()), cx, addons, }))) @@ -197,43 +190,6 @@ impl Bottle { self.0.published.send_replace(None); } - /// Serializes a mutation against the latest state and publishes only after - /// persistence succeeds. - /// - /// The operation may perform external prefix work before `save_state`; such - /// side effects are not automatically reversed if persistence then fails. - pub(super) async fn update( - &self, - cancellation: Option<&CancellationToken>, - operation: F, - ) -> Result - where - F: for<'a, 'b> AsyncFnOnce( - &'a mut BottleState, - Context, - &'b mut Option, - ) -> Result, - { - let mut environment = match cancellation { - Some(cancellation) => cancellation - .run_until_cancelled(self.0.environment.lock()) - .await - .ok_or(Error::Cancelled)?, - None => self.0.environment.lock().await, - }; - if cancellation.is_some_and(CancellationToken::is_cancelled) { - return Err(Error::Cancelled); - } - let mut draft = self.state()?.as_ref().clone(); - let value = operation(&mut draft, self.0.cx.clone(), &mut environment).await?; - if let Err(error) = Self::save_state(&draft, &self.0.cx).await { - *environment = None; - return Err(error); - } - self.publish(draft); - Ok(value) - } - /// Publishes only observable state changes; an equal state does not wake /// watchers. pub(crate) fn publish(&self, state: BottleState) { @@ -258,7 +214,7 @@ impl Bottle { Self::save_state(&state, &self.0.cx).await } - async fn save_state(state: &BottleState, cx: &Context) -> Result<()> { + pub(super) async fn save_state(state: &BottleState, cx: &Context) -> Result<()> { let path = cx.directories().bottle(state.id).join("bottle.toml"); next_config::save(path, state).await?; Ok(()) diff --git a/src/bottle/tests.rs b/src/bottle/tests.rs index 788738d..c406803 100644 --- a/src/bottle/tests.rs +++ b/src/bottle/tests.rs @@ -4,7 +4,6 @@ use std::sync::{ }; use tokio::sync::{Mutex, watch}; -use tokio_util::sync::CancellationToken; use super::state::BottleInner; use crate::{ @@ -29,7 +28,7 @@ async fn deleted_bottle() -> (Bottle, Directories) { let (published, _) = watch::channel(None); let bottle = Bottle(Arc::new(BottleInner { published, - environment: Mutex::new(None), + control: Mutex::new(()), id: uuid::Uuid::new_v4(), cx: context, addons, @@ -38,17 +37,17 @@ async fn deleted_bottle() -> (Bottle, Directories) { } #[test] -fn bottle_update_cancels_while_waiting_for_write_lock() { +fn bottle_edit_cancels_while_waiting_for_write_lock() { futures_lite::future::block_on(async { let (bottle, directories) = deleted_bottle().await; - let write = bottle.0.environment.lock().await; - let cancellation = CancellationToken::new(); + let write = bottle.0.control.lock().await; let ran = Arc::new(AtomicBool::new(false)); let work_ran = ran.clone(); - let mut update = Box::pin(bottle.update(Some(&cancellation), async move |_, _, _| { + let mut update = Box::pin(bottle.edit(move |_| { work_ran.store(true, Ordering::Relaxed); Ok(()) })); + let cancellation = update.cancellation_token(); assert!(futures_lite::future::poll_once(&mut update).await.is_none()); cancellation.cancel(); @@ -64,17 +63,17 @@ fn bottle_update_cancels_while_waiting_for_write_lock() { } #[test] -fn bottle_update_rechecks_cancellation_when_lock_becomes_available() { +fn bottle_edit_rechecks_cancellation_when_lock_becomes_available() { futures_lite::future::block_on(async { let (bottle, directories) = deleted_bottle().await; - let write = bottle.0.environment.lock().await; - let cancellation = CancellationToken::new(); + let write = bottle.0.control.lock().await; let ran = Arc::new(AtomicBool::new(false)); let work_ran = ran.clone(); - let mut update = Box::pin(bottle.update(Some(&cancellation), async move |_, _, _| { + let mut update = Box::pin(bottle.edit(move |_| { work_ran.store(true, Ordering::Relaxed); Ok(()) })); + let cancellation = update.cancellation_token(); assert!(futures_lite::future::poll_once(&mut update).await.is_none()); cancellation.cancel(); diff --git a/src/environment/error.rs b/src/environment/error.rs index dc5828e..41138d9 100644 --- a/src/environment/error.rs +++ b/src/environment/error.rs @@ -5,6 +5,15 @@ use uuid::Uuid; /// Failures in shared execution configuration and operations. #[derive(Debug, Error)] pub enum EnvironmentError { + /// Cleanup could not finish; the prefix remains available for explicit shutdown. + #[error( + "cleanup failed at {prefix}; stop Wine and unmount this prefix before retrying: {source}" + )] + Cleanup { + prefix: std::path::PathBuf, + #[source] + source: Box, + }, #[error("stop the environment before changing its settings")] MustBeStopped, #[error("invalid environment edit: {0}")] diff --git a/src/environment/mod.rs b/src/environment/mod.rs index e4c7964..cff9cb7 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -1,4 +1,4 @@ -//! Shared execution configuration and the private runtime retained by an owner. +//! Shared execution configuration and temporary connections to a running environment. mod config; mod error; @@ -15,7 +15,7 @@ use crate::{ Context, ProgramSpec, error::{Error, Result}, proto::{DllOverride, DllOverrideMode, Process}, - runner::{Runner, shutdown_prefix}, + runner::Runner, winebridge::WineBridgeClient, }; @@ -23,32 +23,39 @@ pub use config::EnvironmentConfig; pub use error::EnvironmentError; pub use prefix::Storage; -/// A private owner-cached live runtime. Construction connects WineBridge. +/// A private connection to a live runtime for one control operation. /// Dropping it only releases local resources. /// Owners serialize access and persist configuration, including storage metadata. pub(crate) struct Environment { - // Retain the resolved runner with the live handle; shutdown uses saved settings. - #[allow(dead_code)] - runner: Box, bridge: WineBridgeClient, } impl Environment { + /// Stops Wine and unmounts storage without requiring a live handle or bridge. + pub(crate) async fn stop(config: &EnvironmentConfig, root: &Path, cx: &Context) -> Result<()> { + let runner = config + .runner() + .load_runner(cx.directories(), config.umu()) + .await?; + shutdown_wine(runner.as_ref(), &root.join("prefix")).await?; + prefix::stop(&config.storage, root, cx).await + } + /// Connects to an existing runtime or prepares and starts one. pub(crate) async fn attach_or_start( config: &EnvironmentConfig, root: PathBuf, cx: Context, ) -> Result { + if let Some(environment) = Self::try_attach(&root).await? { + return Ok(environment); + } let runner = config .runner() .load_runner(cx.directories(), config.umu()) .await?; - let prefix = root.join("prefix"); - if let Some(bridge) = WineBridgeClient::try_connect(&prefix).await? { - return Ok(Self { runner, bridge }); - } prefix::prepare(&config.storage, &root, &cx).await?; + let prefix = root.join("prefix"); let command = config.wrappers.apply( WineBridgeClient::command( runner.as_ref(), @@ -57,8 +64,15 @@ impl Environment { ) .envs(config.env_vars.iter()), ); - let bridge = WineBridgeClient::connect_or_spawn(&prefix, command).await?; - Ok(Self { runner, bridge }) + let bridge = match WineBridgeClient::connect_or_spawn(&prefix, command).await { + Ok(bridge) => bridge, + Err(error) => { + shutdown_wine(runner.as_ref(), &prefix).await?; + prefix::stop(&config.storage, &root, &cx).await?; + return Err(error); + } + }; + Ok(Self { bridge }) } pub(crate) async fn launch_program( @@ -80,6 +94,14 @@ impl Environment { .await } + /// Attaches without starting Wine or mounting storage. + pub(crate) async fn try_attach(root: &Path) -> Result> { + let Some(bridge) = WineBridgeClient::try_connect(&root.join("prefix")).await? else { + return Ok(None); + }; + Ok(Some(Self { bridge })) + } + pub(crate) async fn processes(&self) -> Result> { self.bridge.list_processes().await } @@ -106,26 +128,24 @@ impl Environment { result => result, } } +} - /// Stops Wine and releases storage without requiring a live handle or bridge. - pub(crate) async fn stop(config: &EnvironmentConfig, root: &Path, cx: &Context) -> Result<()> { - let runner = config - .runner() - .load_runner(cx.directories(), config.umu()) - .await?; - let prefix = root.join("prefix"); - match WineBridgeClient::try_connect(&prefix).await { - Ok(Some(bridge)) => { - if let Err(error) = bridge.shutdown().await { - tracing::debug!(%error, "WineBridge shutdown failed; stopping wineserver"); - } - } - Ok(None) => {} - Err(error) => { - tracing::debug!(%error, "WineBridge discovery failed; stopping wineserver"); - } - } - shutdown_prefix(runner.as_ref(), &prefix).await?; - prefix::stop(&config.storage, root, cx).await +/// Stops WineBridge and waits for wineserver; the caller owns storage cleanup. +async fn shutdown_wine(runner: &dyn Runner, prefix: &Path) -> Result<()> { + if let Err(error) = WineBridgeClient::shutdown_existing(prefix).await { + tracing::debug!(%error, "WineBridge shutdown failed; stopping wineserver"); + } + for argument in ["-k", "-w"] { + runner + .wineserver(prefix, argument) + .await + .map_err(|source| EnvironmentError::Cleanup { + prefix: prefix.to_path_buf(), + source: Box::new(source), + })?; + } + if let Err(error) = WineBridgeClient::clear_discovery(prefix).await { + tracing::warn!(%error, "could not remove WineBridge discovery after shutdown"); } + Ok(()) } diff --git a/src/environment/prefix/mod.rs b/src/environment/prefix/mod.rs index ab47cf2..e338d2f 100644 --- a/src/environment/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -1,4 +1,4 @@ -//! Prefix storage backends and checkpointed addon mutation. +//! Prefix storage backends and FVS history primitives. //! //! Standard storage mutates a conventional prefix directly; Virgo stores an //! ordered FVS layer stack with a private writable upper directory. With the default `fvs` @@ -8,26 +8,22 @@ mod standard; #[cfg(feature = "fvs")] mod virgo; -use std::{future::Future, path::Path}; +use std::path::Path; #[cfg(feature = "fvs")] pub use virgo::VirgoError; use serde::{Deserialize, Serialize}; -use tokio_util::sync::CancellationToken; use uuid::Uuid; #[cfg(feature = "fvs")] use { - crate::{Stage, Transfer, error::Error}, + crate::Transfer, futures_core::Stream, futures_util::TryStreamExt, - fvs_rs::{ - Commit, Layer, Progress as FvsProgress, Repository, RestoreResponse, - error::Error as FvsError, - }, + fvs_rs::{Commit, Layer, Progress as FvsProgress, RestoreResponse, error::Error as FvsError}, }; -use crate::{Context, Progress, error::Result, runner::Runner}; +use crate::{Context, error::Result, runner::Runner}; /// Identifies rollback checkpoints that must not appear as user snapshots. /// @@ -126,149 +122,43 @@ pub(crate) async fn rebuild( } } -pub(crate) async fn install( +/// Applies one addon to storage; the enclosing software workflow owns cleanup. +pub(crate) async fn install( storage: &mut Storage, root: &Path, item_id: Uuid, + runner: &dyn Runner, replaced_id: Option, - execute: F, + execute: impl for<'a> std::ops::AsyncFnOnce(&'a Path) -> Result<()>, context: &Context, - cancellation: &CancellationToken, - on_progress: P, -) -> Result<()> -where - F: for<'a> std::ops::AsyncFnOnce(&'a Path) -> Result<()>, - P: FnMut(Progress), -{ - let _ = (item_id, replaced_id); - let work = async { - match storage { - Storage::Standard => standard::install(&root.join("prefix"), execute).await, - #[cfg(feature = "fvs")] - Storage::Virgo { layers } => { - virgo::install(root, layers, item_id, replaced_id, execute, context).await - } +) -> Result<()> { + let _ = (item_id, runner, replaced_id, context); + match storage { + Storage::Standard => execute(&root.join("prefix")).await, + #[cfg(feature = "fvs")] + Storage::Virgo { layers } => { + virgo::install(root, layers, item_id, runner, replaced_id, execute, context).await } - }; - transact(root, context, work, cancellation, on_progress).await + } } -pub(crate) async fn uninstall( +pub(crate) async fn uninstall( storage: &mut Storage, root: &Path, item_id: Uuid, - execute: F, - context: &Context, - cancellation: &CancellationToken, - on_progress: P, -) -> Result<()> -where - F: for<'a> std::ops::AsyncFnOnce(&'a Path, bool) -> Result<()>, - P: FnMut(Progress), -{ - let _ = item_id; - let work = async { - match storage { - Storage::Standard => standard::uninstall(&root.join("prefix"), execute).await, - #[cfg(feature = "fvs")] - Storage::Virgo { layers } => { - virgo::uninstall(root, layers, item_id, execute, context).await - } - } - }; - transact(root, context, work, cancellation, on_progress).await -} - -/// Runs a prefix mutation behind a rollback checkpoint. -/// -/// Cancellation is checked after checkpointing and after successful work. Any -/// work error or observed cancellation triggers a restore. If restore also -/// fails, the restore failure is logged and the original error is preserved. -/// Dropping the surrounding [`crate::Operation`] abandons this future and does -/// not drive the restore path. -#[cfg(feature = "fvs")] -async fn transact( - root: &Path, + execute: impl for<'a> std::ops::AsyncFnOnce(&'a Path, bool) -> Result<()>, context: &Context, - work: F, - cancellation: &CancellationToken, - mut on_progress: P, -) -> Result -where - F: Future>, - P: FnMut(Progress), -{ - let repository = Repository { - repository_path: root.display().to_string(), - block_size: FVS_BLOCK_SIZE, - }; - let stream = context - .fvs() - .await? - .commit_stream(&repository, AUTO_CHECKPOINT_MESSAGE.into()) - .await?; - let checkpoint = finish_commit(stream, |progress| { - on_progress(Progress::transferring( - Stage::Checkpointing, - progress.into(), - )); - }) - .await?; - - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - - let result = work.await; - let result = if result.is_ok() && cancellation.is_cancelled() { - Err(Error::Cancelled) - } else { - result - }; - match result { - Ok(value) => Ok(value), - Err(error) => { - let restored = async { - let client = context.fvs().await?; - let stream = client - .restore_stream( - &repository, - &checkpoint.state_id, - None::<&Path>, - true, - false, - ) - .await?; - finish_restore(stream, |progress| { - on_progress(Progress::transferring(Stage::Restoring, progress.into())); - }) - .await - } - .await; - if let Err(failed) = restored { - tracing::error!(%failed, "prefix rollback failed after {error}"); - } - Err(error) +) -> Result<()> { + let _ = (item_id, context); + match storage { + Storage::Standard => execute(&root.join("prefix"), true).await, + #[cfg(feature = "fvs")] + Storage::Virgo { layers } => { + virgo::uninstall(root, layers, item_id, execute, context).await } } } -/// Runs a prefix mutation directly when FVS rollback support is not compiled in. -#[cfg(not(feature = "fvs"))] -async fn transact( - _root: &Path, - _context: &Context, - work: F, - _cancellation: &CancellationToken, - _on_progress: P, -) -> Result -where - F: Future>, - P: FnMut(Progress), -{ - work.await -} - /// Drains an FVS commit stream, forwarding every frame and requiring a terminal commit. #[cfg(feature = "fvs")] pub(crate) async fn finish_commit( @@ -323,11 +213,7 @@ async fn finish_stream( #[cfg(all(test, feature = "fvs"))] mod fvs_tests { - use std::io; - use futures_util::stream; - use tokio_util::sync::CancellationToken; - use uuid::Uuid; use super::*; @@ -385,90 +271,4 @@ mod fvs_tests { assert_eq!(commit.state_id, "checkpoint"); }); } - - #[test] - #[ignore = "requires BOTTLES_TEST_FVS2D"] - fn failed_transaction_restores_its_checkpoint() { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(async { - let executable = - std::env::var_os("BOTTLES_TEST_FVS2D").expect("BOTTLES_TEST_FVS2D is required"); - let root = std::env::temp_dir().join(format!("bottles-next-{}", Uuid::new_v4())); - let directories = crate::Directories::from_path(root.join("data")).unwrap(); - let socket = directories.runtime_dir().join("fvs2d.sock"); - let context = - crate::Context::for_test(directories.clone(), Some(executable.into())).unwrap(); - let owner_path = directories.data_dir().join("owner"); - std::fs::create_dir_all(&owner_path).unwrap(); - context - .fvs() - .await - .unwrap() - .new_repository(&owner_path, FVS_BLOCK_SIZE) - .await - .unwrap(); - let file = owner_path.join("value"); - async_fs::write(&file, "before").await.unwrap(); - - let changed = file.clone(); - let result = transact( - &owner_path, - &context, - async move { - async_fs::write(changed, "after").await?; - Err::<(), _>(io::Error::other("expected failure").into()) - }, - &CancellationToken::new(), - |_| {}, - ) - .await; - - assert!(result.is_err()); - assert_eq!(async_fs::read_to_string(file).await.unwrap(), "before"); - fvs_rs::Fvs2dClient::connect(socket) - .await - .unwrap() - .shutdown(fvs_rs::UnmountMode::Lazy) - .await - .unwrap(); - std::fs::remove_dir_all(root).unwrap(); - }); - } -} - -#[cfg(all(test, not(feature = "fvs")))] -mod no_fvs_tests { - use std::io; - - use super::*; - - #[test] - fn transaction_runs_directly_and_propagates_failure() { - futures_lite::future::block_on(async { - let directories = crate::Directories::from_path( - std::env::temp_dir().join(format!("bottles-next-{}", Uuid::new_v4())), - ) - .unwrap(); - let context = crate::Context::for_test(directories, None).unwrap(); - let mut ran = false; - - let result = transact( - Path::new("unused"), - &context, - async { - ran = true; - Err::<(), _>(io::Error::other("expected failure").into()) - }, - &CancellationToken::new(), - |_| panic!("direct mutation must not report FVS progress"), - ) - .await; - - assert!(ran); - assert!(matches!(result, Err(crate::error::Error::Io(_)))); - }); - } } diff --git a/src/environment/prefix/standard.rs b/src/environment/prefix/standard.rs index d567606..269b2b4 100644 --- a/src/environment/prefix/standard.rs +++ b/src/environment/prefix/standard.rs @@ -2,29 +2,14 @@ //! //! Recipes operate on `/prefix`. Uninstallation asks the recipe to //! restore overwritten files because, unlike Virgo, this backend has no lower -//! layer to reveal. Transaction rollback is provided by the parent module. +//! layer to reveal. The software workflow owns mutation rollback. -use std::{ops::AsyncFnOnce, path::Path}; +use std::path::Path; -use crate::{ - error::Result, - runner::{Runner, initialize_and_shutdown_prefix}, -}; +use crate::{error::Result, runner::Runner}; pub(super) async fn create(prefix: &Path, runner: &dyn Runner) -> Result<()> { - initialize_and_shutdown_prefix(runner, prefix).await -} - -pub(super) async fn install(prefix: &Path, execute: F) -> Result<()> -where - F: for<'a> AsyncFnOnce(&'a Path) -> Result<()>, -{ - execute(prefix).await -} - -pub(super) async fn uninstall(prefix: &Path, execute: F) -> Result<()> -where - F: for<'a> AsyncFnOnce(&'a Path, bool) -> Result<()>, -{ - execute(prefix, true).await + let result = runner.wineboot(prefix, "--init").await; + crate::environment::shutdown_wine(runner, prefix).await?; + result } diff --git a/src/environment/prefix/virgo/cache.rs b/src/environment/prefix/virgo/cache.rs index 19b2394..fc5c7cf 100644 --- a/src/environment/prefix/virgo/cache.rs +++ b/src/environment/prefix/virgo/cache.rs @@ -10,19 +10,20 @@ use std::{ path::{Path, PathBuf}, }; -use fvs_rs::Layer; +use fvs_rs::{Layer, UnmountMode}; use regdiff_rs::prelude::{Diff, Hive, Registry, apply_files}; use uuid::Uuid; use crate::{ Context, error::{Error, Result}, + runner::Runner, }; use super::super::FVS_BLOCK_SIZE; -use super::{VirgoError, with_mount}; +use super::VirgoError; -/// Removes references from one owner's stack without deleting the shared cache. +/// Removes references without deleting a shared cache. pub(super) fn remove(layers: &mut Vec, id: Uuid, context: &Context) { let repository = layer_path(id, context).display().to_string(); layers.retain(|layer| layer.repository_path != repository); @@ -50,6 +51,7 @@ pub(super) async fn exists(id: Uuid, context: &Context) -> Result { pub(super) async fn install( layers: Vec, item_id: Uuid, + runner: &dyn Runner, execute: F, context: &Context, ) -> Result<()> @@ -84,34 +86,47 @@ where return Err(error); } - let result = async { - with_mount(&prefix, layers, Some(&upper), context, async |mount| { - for (file, _) in registry_files() { - async_fs::copy(prefix.join(file), before.join(file)).await?; + let client = context.fvs().await?; + let mount = client.mount(&prefix, layers, Some(&upper)).await?; + let installed = async { + for (file, _) in registry_files() { + async_fs::copy(prefix.join(file), before.join(file)).await?; + } + execute(&prefix).await + } + .await; + crate::environment::shutdown_wine(runner, &prefix).await?; + let diffed: Result<()> = async { + installed?; + let diff_before = before.clone(); + let diff_prefix = prefix.clone(); + let diff_patches = patches.clone(); + blocking::unblock(move || { + for (file, hive) in registry_files() { + write_forward( + &diff_before.join(file), + &diff_prefix.join(file), + &diff_patches.join(file), + hive, + )?; } - - execute(&prefix).await?; - - let diff_before = before.clone(); - let diff_prefix = prefix.clone(); - let diff_patches = patches.clone(); - blocking::unblock(move || { - for (file, hive) in registry_files() { - write_forward( - &diff_before.join(file), - &diff_prefix.join(file), - &diff_patches.join(file), - hive, - )?; - } - Ok::<_, Error>(()) - }) - .await?; - context.fvs().await?.diff_mount(mount, true).await?; - Ok(()) + Ok::<_, Error>(()) }) .await?; + client.diff_mount(&mount, true).await?; + Ok(()) + } + .await; + client + .unmount(&mount, UnmountMode::Normal) + .await + .map_err(|source| crate::EnvironmentError::Cleanup { + prefix: prefix.clone(), + source: Box::new(source.into()), + })?; + let result: Result<()> = async { + diffed?; for (file, _) in registry_files() { remove_file(&upper.join(file)).await?; } @@ -150,39 +165,30 @@ pub(super) async fn apply_registry( return Ok(()); } + super::prepare(root, layers, context).await?; let prefix = root.join("prefix"); - let upper = root.join("upper"); - with_mount( - &prefix, - layers.to_vec(), - Some(&upper), - context, - async |_| { - let apply_prefix = prefix.clone(); - let stage = prefix.join(format!(".bottles-next-registry-{}", Uuid::new_v4())); - blocking::unblock(move || { - fs::create_dir_all(&stage)?; - let result = (|| { - for (file, hive) in registry_files() { - apply_files( - apply_prefix.join(file), - patches.join(file), - stage.join(file), - hive, - ) - .map_err(|error| VirgoError::Registry(error.to_string()))?; - } - for (file, _) in registry_files() { - fs::rename(stage.join(file), apply_prefix.join(file))?; - } - Ok::<_, Error>(()) - })(); - let _ = fs::remove_dir_all(stage); - result - }) - .await - }, - ) + let apply_prefix = prefix.clone(); + let stage = prefix.join(format!(".bottles-next-registry-{}", Uuid::new_v4())); + blocking::unblock(move || { + fs::create_dir_all(&stage)?; + let result = (|| { + for (file, hive) in registry_files() { + apply_files( + apply_prefix.join(file), + patches.join(file), + stage.join(file), + hive, + ) + .map_err(|error| VirgoError::Registry(error.to_string()))?; + } + for (file, _) in registry_files() { + fs::rename(stage.join(file), apply_prefix.join(file))?; + } + Ok::<_, Error>(()) + })(); + let _ = fs::remove_dir_all(stage); + result + }) .await } diff --git a/src/environment/prefix/virgo/mod.rs b/src/environment/prefix/virgo/mod.rs index 74996d6..c13135b 100644 --- a/src/environment/prefix/virgo/mod.rs +++ b/src/environment/prefix/virgo/mod.rs @@ -13,13 +13,13 @@ use std::{ }; use futures_lite::StreamExt; -use fvs_rs::{Layer, Mount, Repository, UnmountMode}; +use fvs_rs::{Layer, Repository, UnmountMode}; use uuid::Uuid; use crate::{ Context, error::{Error, Result}, - runner::{Runner, initialize_and_shutdown_prefix}, + runner::Runner, }; use super::FVS_BLOCK_SIZE; @@ -44,6 +44,10 @@ pub enum VirgoError { /// Virgo cannot mount a prefix over a nonempty mountpoint. #[error("mountpoint is not empty: {0}")] DirtyMountpoint(PathBuf), + #[error( + "mounted layers or writable upper differ from saved configuration at {0}; call stop() and retry" + )] + MountMismatch(PathBuf), /// A cached layer required to construct the prefix is missing. #[error("cached Virgo layer was not found: {0}")] CachedLayerNotFound(PathBuf), @@ -64,11 +68,54 @@ pub(super) async fn create( } pub(super) async fn prepare(root: &Path, layers: &[Layer], context: &Context) -> Result<()> { - mount_layers(root, layers.to_vec(), context).await + if !existing_mount(root, layers, context).await? { + let prefix = root.join("prefix"); + ensure_empty_dir(&prefix).await?; + context + .fvs() + .await? + .mount(&prefix, layers.to_vec(), Some(root.join("upper"))) + .await?; + } + Ok(()) +} + +async fn existing_mount(root: &Path, layers: &[Layer], context: &Context) -> Result { + let prefix = root.join("prefix"); + let mounts = context.fvs().await?.list_mounts().await?; + let Some(spec) = mounts + .into_iter() + .filter_map(|mount| mount.spec) + .find(|spec| spec.mount_point == prefix.to_string_lossy()) + else { + return Ok(false); + }; + if spec.layers != layers + || spec.upper_path.as_deref() != Some(root.join("upper").to_string_lossy().as_ref()) + { + return Err(VirgoError::MountMismatch(prefix).into()); + } + Ok(true) } pub(super) async fn stop(root: &Path, context: &Context) -> Result<()> { - unmount_prefix(root, context).await + let prefix = root.join("prefix"); + let client = context.fvs().await?; + if let Some(mount) = client.list_mounts().await?.into_iter().find(|mount| { + mount + .spec + .as_ref() + .is_some_and(|spec| spec.mount_point == prefix.to_string_lossy()) + }) { + client + .unmount(&mount, UnmountMode::Normal) + .await + .map_err(|source| crate::EnvironmentError::Cleanup { + prefix, + source: Box::new(source.into()), + })?; + } + Ok(()) } pub(super) async fn rebuild( @@ -92,6 +139,7 @@ pub(super) async fn install( root: &Path, layers: &mut Vec, item_id: Uuid, + runner: &dyn Runner, replaced_id: Option, execute: F, context: &Context, @@ -102,7 +150,7 @@ where // A cache hit deliberately skips the recipe. The cached filesystem layer and // registry patch must therefore capture every prefix effect of installation. if !cache::exists(item_id, context).await? { - cache::install(layers.clone(), item_id, execute, context).await?; + cache::install(layers.clone(), item_id, runner, execute, context).await?; } let cached = cache::layer(item_id, context).await?; @@ -127,83 +175,9 @@ where // Removing the layer reveals the previous filesystem contents, so the recipe // must not restore overwritten files into the writable upper directory. cache::remove(layers, item_id, context); - let prefix = root.join("prefix"); - let upper = root.join("upper"); - with_mount(&prefix, layers.clone(), Some(&upper), context, async |_| { - execute(&prefix, false).await - }) - .await -} - -/// Mounts for the duration of `work` and always attempts a normal unmount. -/// -/// An unmount failure becomes the result only when `work` succeeded. If both -/// fail, the work error is preserved and the unmount failure is logged. -async fn with_mount( - mountpoint: &Path, - layers: Vec, - upper: Option<&Path>, - context: &Context, - work: F, -) -> Result -where - F: for<'a> AsyncFnOnce(&'a Mount) -> Result, -{ - ensure_empty_dir(mountpoint).await?; - let client = context.fvs().await?; - let mount = client.mount(mountpoint, layers, upper).await?; - let result = work(&mount).await; - let unmounted = client.unmount(&mount, UnmountMode::Normal).await; - - match result { - Ok(value) => { - unmounted?; - Ok(value) - } - Err(error) => { - if let Err(failed) = unmounted { - tracing::error!(%failed, "unmount failed after {error}"); - } - Err(error) - } - } -} - -/// Prepares an owner's long-lived Virgo mount. -/// -/// An existing mount at the same path is trusted without comparing its layer -/// specification. Callers must stop the owner before changing persisted layers. -async fn mount_layers(root: &Path, layers: Vec, context: &Context) -> Result<()> { - let prefix = root.join("prefix"); - let mountpoint = prefix.display().to_string(); - let client = context.fvs().await?; - if client.list_mounts().await?.into_iter().any(|mount| { - mount - .spec - .as_ref() - .is_some_and(|spec| spec.mount_point == mountpoint) - }) { - return Ok(()); - } - ensure_empty_dir(&prefix).await?; - client - .mount(&prefix, layers, Some(root.join("upper"))) - .await?; - Ok(()) -} - -async fn unmount_prefix(root: &Path, context: &Context) -> Result<()> { - let mountpoint = root.join("prefix").display().to_string(); - let client = context.fvs().await?; - if let Some(mount) = client.list_mounts().await?.into_iter().find(|mount| { - mount - .spec - .as_ref() - .is_some_and(|spec| spec.mount_point == mountpoint) - }) { - client.unmount(&mount, UnmountMode::Normal).await?; - } - Ok(()) + prepare(root, layers, context).await?; + // The enclosing transaction shuts Wine down and unmounts before rollback. + execute(&root.join("prefix"), false).await } async fn base_layers( @@ -254,7 +228,9 @@ async fn ensure_base(runner: &dyn Runner, context: &Context) -> Result { return Ok(Layer::from_summary(&repository, Some(&commit))); } - if let Err(error) = initialize_and_shutdown_prefix(runner, &repository_path).await { + let initialized = runner.wineboot(&repository_path, "--init").await; + crate::environment::shutdown_wine(runner, &repository_path).await?; + if let Err(error) = initialized { remove_dir(base_path).await; return Err(error); } @@ -312,15 +288,21 @@ async fn ensure_adapter( async_fs::create_dir_all(&upper).await?; async_fs::create_dir_all(&mountpoint).await?; - let build = async { - with_mount( - &mountpoint, - vec![base.clone()], - Some(&upper), - context, - async |_| initialize_and_shutdown_prefix(runner, &mountpoint).await, - ) + let client = context.fvs().await?; + let mount = client + .mount(&mountpoint, vec![base.clone()], Some(&upper)) .await?; + let initialized = runner.wineboot(&mountpoint, "--init").await; + crate::environment::shutdown_wine(runner, &mountpoint).await?; + client + .unmount(&mount, UnmountMode::Normal) + .await + .map_err(|source| crate::EnvironmentError::Cleanup { + prefix: mountpoint.clone(), + source: Box::new(source.into()), + })?; + let build = async { + initialized?; let client = context.fvs().await?; let repository = client.new_repository(&upper, FVS_BLOCK_SIZE).await?; diff --git a/src/environment/software.rs b/src/environment/software.rs index 6666c13..c885ee9 100644 --- a/src/environment/software.rs +++ b/src/environment/software.rs @@ -6,11 +6,12 @@ use strum::IntoEnumIterator; use tokio::sync::watch; use tokio_util::sync::CancellationToken; -use super::{EnvironmentConfig, EnvironmentError, prefix}; +use super::{EnvironmentConfig, EnvironmentError, Storage, prefix}; use crate::{ Addon, AddonError, Addons, Context, Progress, Slot, Stage, addons::{Artifact, InstallInputs, execute, replay_env_vars, uninstall}, error::{Error, Result}, + runner::Runner, }; /// Called while the owner is coordinated and stopped. The owner publishes @@ -107,6 +108,7 @@ pub(crate) async fn reconcile( if !runner_changed && removals.is_empty() && installations.is_empty() { return Ok(()); } + super::Environment::stop(previous, root, cx).await?; let runner = candidate .runner() .load_runner(cx.directories(), candidate.umu()) @@ -115,33 +117,40 @@ pub(crate) async fn reconcile( let env_vars = &mut candidate.env_vars; for (id, resources) in &removals { - prefix::uninstall( + transact( &mut candidate.storage, root, - *id, - async |prefix, restore_files| { - uninstall( - InstallInputs { - prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars, - }, - resources, - restore_files, + runner.as_ref(), + cx, + cancellation, + progress, + async |storage| { + prefix::uninstall( + storage, + root, *id, - cancellation, - |_| { - progress.send_replace(Some(Progress::new(Stage::Removing))); + async |prefix, restore_files| { + uninstall( + InstallInputs { + prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars, + }, + resources, + restore_files, + *id, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Removing))); + }, + ) + .await }, + cx, ) .await }, - cx, - cancellation, - |event| { - progress.send_replace(Some(event)); - }, ) .await?; } @@ -167,35 +176,113 @@ pub(crate) async fn reconcile( .await?; } for (id, replaced, resources) in installations { - prefix::install( + transact( &mut candidate.storage, root, - id, - replaced, - async |prefix| { - execute( - InstallInputs { - prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars, - }, - &resources, - cancellation, - |_| { - progress.send_replace(Some(Progress::new(Stage::Configuring))); + runner.as_ref(), + cx, + cancellation, + progress, + async |storage| { + prefix::install( + storage, + root, + id, + runner.as_ref(), + replaced, + async |prefix| { + execute( + InstallInputs { + prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars, + }, + &resources, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Configuring))); + }, + ) + .await }, + cx, ) .await }, - cx, - cancellation, - |event| { - progress.send_replace(Some(event)); - }, ) .await?; replay_env_vars(env_vars, &resources); } Ok(()) } + +/// Coordinates one addon mutation. Owner configuration is saved separately. +/// Failed shutdown/unmount returns before any rollback can touch live storage. +async fn transact( + storage: &mut Storage, + root: &Path, + runner: &dyn Runner, + cx: &Context, + cancellation: &CancellationToken, + progress: &watch::Sender>, + work: impl for<'a> std::ops::AsyncFnOnce(&'a mut Storage) -> Result<()>, +) -> Result<()> { + #[cfg(feature = "fvs")] + let repository = fvs_rs::Repository { + repository_path: root.display().to_string(), + block_size: prefix::FVS_BLOCK_SIZE, + }; + #[cfg(feature = "fvs")] + let checkpoint = { + let stream = cx + .fvs() + .await? + .commit_stream(&repository, prefix::AUTO_CHECKPOINT_MESSAGE.into()) + .await?; + prefix::finish_commit(stream, |event| { + progress.send_replace(Some(Progress::transferring( + Stage::Checkpointing, + event.into(), + ))); + }) + .await? + }; + let _ = progress; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let result = work(storage).await; + super::shutdown_wine(runner, &root.join("prefix")).await?; + prefix::stop(storage, root, cx).await?; + let result = if result.is_ok() && cancellation.is_cancelled() { + Err(Error::Cancelled) + } else { + result + }; + #[cfg(feature = "fvs")] + if let Err(error) = &result { + let restored = async { + let stream = cx + .fvs() + .await? + .restore_stream( + &repository, + &checkpoint.state_id, + None::<&Path>, + true, + false, + ) + .await?; + prefix::finish_restore(stream, |event| { + progress.send_replace(Some(Progress::transferring(Stage::Restoring, event.into()))); + }) + .await + } + .await; + if let Err(failed) = restored { + tracing::error!(%failed, "prefix rollback failed after {error}"); + } + } + result +} diff --git a/src/runner/mod.rs b/src/runner/mod.rs index d8471f1..b2fd750 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -106,25 +106,6 @@ pub(crate) trait Runner: Send + Sync { async fn wineserver(&self, prefix: &Path, arg: &str) -> Result<()>; } -/// Initializes a prefix and then attempts to stop its server. -/// -/// Shutdown is attempted even when initialization fails. If both fail, the -/// initialization error takes precedence. -pub(crate) async fn initialize_and_shutdown_prefix( - runner: &dyn Runner, - prefix: &Path, -) -> Result<()> { - let initialized = runner.wineboot(prefix, "--init").await; - let stopped = shutdown_prefix(runner, prefix).await; - initialized?; - stopped -} - -pub(crate) async fn shutdown_prefix(runner: &dyn Runner, prefix: &Path) -> Result<()> { - runner.wineserver(prefix, "-k").await?; - runner.wineserver(prefix, "-w").await -} - /// Classifies a component by its regular-file markers. /// /// `proton` takes precedence over `bin/wine` when both exist. Missing markers diff --git a/src/runner/proton.rs b/src/runner/proton.rs index 0717488..5a30056 100644 --- a/src/runner/proton.rs +++ b/src/runner/proton.rs @@ -96,9 +96,8 @@ mod tests { .status() .await .unwrap(); - crate::runner::shutdown_prefix(&runner, &prefix) - .await - .unwrap(); + runner.wineserver(&prefix, "-k").await.unwrap(); + runner.wineserver(&prefix, "-w").await.unwrap(); assert!(matches!( runner.wineboot(&prefix, "--fail").await, Err(crate::error::Error::Runner(RunnerError::WinebootFailed(_))) diff --git a/src/winebridge.rs b/src/winebridge.rs index 14aee8b..8936dde 100644 --- a/src/winebridge.rs +++ b/src/winebridge.rs @@ -37,6 +37,10 @@ pub enum BridgeError { BridgeExited(ExitStatus), #[error("WineBridge did not report readiness before the startup timeout elapsed.")] Timeout, + #[error( + "WineBridge discovery exists but the runtime is unreachable at {0}; call stop() and retry" + )] + Unavailable(PathBuf), #[error("WineBridge did not stop before the shutdown timeout elapsed.")] ShutdownTimeout, #[error("WineBridge returned an invalid response: {0}")] @@ -57,7 +61,7 @@ async fn endpoint_from_port_file(path: &Path) -> Result> { .ok() .filter(|port| *port != 0) .ok_or(BridgeError::InvalidResponse( - "WineBridge published an invalid port", + "WineBridge published an invalid port; call stop() and retry", ))?; Ok(Some(Endpoint::from_shared(format!( "http://127.0.0.1:{port}" @@ -103,13 +107,13 @@ impl WineBridgeClient { let ready = async { loop { if let Some(status) = process.try_status()? { - if let Some(client) = Self::try_connect(prefix).await? { + if let Some(client) = Self::probe(prefix).await? { return Ok(client); } return Err(BridgeError::BridgeExited(status).into()); } - if let Some(client) = Self::try_connect(prefix).await? { + if let Some(client) = Self::probe(prefix).await? { return Ok(client); } @@ -117,29 +121,65 @@ impl WineBridgeClient { } }; - future::race(ready, async { + let result = future::race(ready, async { Timer::after(Duration::from_secs(30)).await; Err(BridgeError::Timeout.into()) }) - .await + .await; + if result.is_err() { + if let Err(error) = process.kill() + && error.kind() != io::ErrorKind::InvalidInput + { + return Err(error.into()); + } + process.status().await?; + } + result } pub(crate) async fn try_connect(prefix: &Path) -> Result> { + let bridge = Self::probe(prefix).await?; + if bridge.is_none() && exists(&Self::port_file(prefix)).await? { + return Err(BridgeError::Unavailable(prefix.to_owned()).into()); + } + Ok(bridge) + } + + pub(crate) async fn shutdown_existing(prefix: &Path) -> Result<()> { + if let Some(bridge) = Self::try_connect(prefix).await? { + bridge.shutdown().await?; + } + Ok(()) + } + + pub(crate) async fn clear_discovery(prefix: &Path) -> Result<()> { + match async_fs::remove_file(Self::port_file(prefix)).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } + + async fn probe(prefix: &Path) -> Result> { let port_file = Self::port_file(prefix); let Some(endpoint) = endpoint_from_port_file(&port_file).await? else { return Ok(None); }; - let Ok(channel) = endpoint.connect().await else { + let Ok(channel) = endpoint + .connect_timeout(Duration::from_secs(2)) + .connect() + .await + else { return Ok(None); }; - let response = HealthClient::new(channel.clone()) - .check(HealthCheckRequest { - service: proto::wine_bridge_server::SERVICE_NAME.to_string(), - }) - .await; + let mut request = tonic::Request::new(HealthCheckRequest { + service: proto::wine_bridge_server::SERVICE_NAME.to_string(), + }); + request.set_timeout(Duration::from_secs(2)); + let response = HealthClient::new(channel.clone()).check(request).await; Ok(matches!(response, Ok(response) if response.get_ref().status() == ServingStatus::Serving) .then(|| Self { @@ -657,7 +697,9 @@ impl WineBridgeClient { /// Returns an error if the shutdown RPC fails. pub async fn shutdown(&self) -> Result<()> { let mut client = self.client.clone(); - client.shutdown(()).await?; + let mut request = tonic::Request::new(()); + request.set_timeout(Duration::from_secs(5)); + client.shutdown(request).await?; drop(client); for _ in 0..50 { if !exists(&self.port_file).await? { From 6b03147b654d4396d5326563ccc3a084a76c0432 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Fri, 11 Sep 2026 02:32:00 +0530 Subject: [PATCH 07/24] fix(core): remove implicit FVS requirements from standard prefixes --- README.md | 15 ++++++++------- src/bottle/edit.rs | 1 + src/bottle/manager.rs | 13 +++++++------ src/bottle/mod.rs | 4 ++-- src/bottle/snapshot.rs | 17 +++++++++++++---- src/environment/prefix/mod.rs | 7 +++---- src/environment/software.rs | 25 +++++++++++++++---------- 7 files changed, 49 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 6ccd0bc..56b9e42 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,9 @@ The application core for managing Bottles Next Wine and Proton environments. `bottles-core` discovers and installs managed components, persists bottles, -executes Windows programs through WineBridge, and provides checkpointed prefix -mutation and snapshots through the default `fvs` feature. +executes Windows programs through WineBridge, and provides Virgo storage and +snapshots through the default `fvs` feature. Standard creation, launch, and addon +changes work without FVS even when that feature is compiled in. Disable FVS when only conventional, directly mutable prefixes are needed: @@ -13,8 +14,9 @@ Disable FVS when only conventional, directly mutable prefixes are needed: bottles-core = { version = "0.1", default-features = false } ``` -Without `fvs`, snapshot APIs and Virgo storage are not compiled, and failed or -cancelled addon recipes are not rolled back automatically. +Without `fvs`, snapshot APIs and Virgo storage are not compiled. Standard addon +changes always use direct writes; failed or cancelled recipes can leave partial +prefix changes. Explicit Standard snapshots initialize FVS history on demand. [Source] | [Issue tracker] @@ -61,9 +63,8 @@ New dependency selections can be appended. Prefix changes run before publication batch rollback of those effects remains part of the later composition work. Settings-only edits save the draft without preparing or mutating the prefix. -Standard no-FVS operation, pinned Soda builds, and managed registry-baseline -composition remain separate later steps. Existing per-addon layer and checkpoint -behavior remains in place for now. +Pinned Soda builds and managed registry-baseline composition remain later steps. +Virgo retains its existing per-addon layer and checkpoint behavior. Bottle configuration requires execution settings under `environment`, with resolved FVS layers retained inside `environment.storage` for Virgo. Old diff --git a/src/bottle/edit.rs b/src/bottle/edit.rs index 1883441..162dc4d 100644 --- a/src/bottle/edit.rs +++ b/src/bottle/edit.rs @@ -16,6 +16,7 @@ impl Bottle { /// Metadata can change while running. Environment changes require an explicit /// stop first. Storage and existing dependency order cannot be changed; new /// dependencies may be appended. Addon selections must be downloaded. + /// Standard mutations write directly; failed recipes can leave partial effects. /// Prefix effects are not yet rolled back as a batch if reconciliation or /// persistence fails; no candidate configuration is published on failure. pub fn edit( diff --git a/src/bottle/manager.rs b/src/bottle/manager.rs index 9498dcc..87a0d9d 100644 --- a/src/bottle/manager.rs +++ b/src/bottle/manager.rs @@ -146,8 +146,7 @@ impl BottleManager { /// The newest downloaded WineBridge is selected automatically. A runner /// requiring UMU also receives the newest downloaded UMU release. No addon /// is downloaded implicitly. The runner UUID must identify a downloaded - /// runner component. With the default `fvs` feature, creation requires the - /// configured FVS service even for [`Storage::Standard`]. Failures, and + /// runner component. Standard creation does not require FVS. Failures, and /// cancellation observed while the operation remains polled, remove the /// partially-created bottle directory on a best-effort basis. Dropping a /// started operation or a cleanup failure can leave a directory that a @@ -250,10 +249,12 @@ impl BottleManager { .await?; progress.send_replace(Some(Progress::new(Stage::Configuring))); #[cfg(feature = "fvs")] - cx.fvs() - .await? - .new_repository(&bottle_path, FVS_BLOCK_SIZE) - .await?; + if matches!(&bottle.state()?.environment.storage, Storage::Virgo { .. }) { + cx.fvs() + .await? + .new_repository(&bottle_path, FVS_BLOCK_SIZE) + .await?; + } if cancellation.is_cancelled() { return Err(Error::Cancelled); } diff --git a/src/bottle/mod.rs b/src/bottle/mod.rs index 099d5fa..ead1dc9 100644 --- a/src/bottle/mod.rs +++ b/src/bottle/mod.rs @@ -13,8 +13,8 @@ //! reloading externally modified files. Component and dependency records are pinned in each //! persisted state until a bottle operation explicitly replaces them. //! -//! With the default `fvs` feature, every bottle has an FVS repository for -//! caller-visible snapshots and rollback checkpoints around addon changes. +//! With the default `fvs` feature, bottles support caller-visible snapshots. +//! Standard history is created on demand; Virgo also checkpoints addon changes. //! Long-running mutations return lazy //! [`crate::Operation`] values and serialize with edits, stopping, snapshots, //! and deletion. WineBridge-backed control calls share that coordination. diff --git a/src/bottle/snapshot.rs b/src/bottle/snapshot.rs index c57d575..d5d0a0c 100644 --- a/src/bottle/snapshot.rs +++ b/src/bottle/snapshot.rs @@ -17,7 +17,7 @@ impl Bottle { /// /// The operation takes exclusive bottle access and stops the bottle before /// inspecting the complete library-managed bottle directory, including - /// `bottle.toml`. + /// `bottle.toml`. Standard history is initialized on the first snapshot. /// /// If the tree has not changed, no history entry is created. The returned /// [`Snapshot`] then has `created == false`, and its state ID, message, and @@ -53,7 +53,13 @@ impl Bottle { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - let stream = cx.fvs().await?.commit_stream(&repository, message).await?; + let client = cx.fvs().await?; + if !crate::utils::exists(&bottle.bottle_path().join(".fvs2")).await? { + client + .new_repository(bottle.bottle_path(), FVS_BLOCK_SIZE) + .await?; + } + let stream = client.commit_stream(&repository, message).await?; finish_commit(stream, |update| { progress.send_replace(Some(Progress::transferring( Stage::Committing, @@ -71,6 +77,7 @@ impl Bottle { /// for internal mutation checkpoints. /// /// Listing serializes with runtime control, edits and deletion. + /// A bottle without history returns an empty list without contacting FVS. /// /// # Errors /// @@ -79,6 +86,9 @@ impl Bottle { pub async fn snapshots(&self) -> Result> { let _read = self.0.control.lock().await; self.ensure_exists()?; + if !crate::utils::exists(&self.bottle_path().join(".fvs2")).await? { + return Ok(Vec::new()); + } let repository = self.snapshot_repository(); Ok(self .0 @@ -163,8 +173,7 @@ impl Bottle { }) } - /// Addresses the history repository that every bottle owns independently - /// of its prefix storage strategy. + /// Addresses owner history, created on demand for Standard snapshots. fn snapshot_repository(&self) -> Repository { Repository { repository_path: self.bottle_path().display().to_string(), diff --git a/src/environment/prefix/mod.rs b/src/environment/prefix/mod.rs index e338d2f..493d1d2 100644 --- a/src/environment/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -1,8 +1,8 @@ //! Prefix storage backends and FVS history primitives. //! //! Standard storage mutates a conventional prefix directly; Virgo stores an -//! ordered FVS layer stack with a private writable upper directory. With the default `fvs` -//! feature, addon installation and removal use an FVS rollback checkpoint. +//! ordered FVS layer stack with a private writable upper directory. Virgo addon +//! changes use rollback checkpoints; Standard uses FVS only for explicit snapshots. mod standard; #[cfg(feature = "fvs")] @@ -39,8 +39,7 @@ pub(crate) const FVS_BLOCK_SIZE: u32 = 1024 * 1024; pub enum Storage { /// Stores a conventional mutable prefix in the owner directory. /// - /// With the default `fvs` feature, FVS also provides snapshots and addon - /// mutation checkpoints. + /// Explicit snapshots may use FVS; ordinary mutations use direct writes. Standard, /// Stores the prefix as composable FVS layers. /// diff --git a/src/environment/software.rs b/src/environment/software.rs index c885ee9..7e2ddc7 100644 --- a/src/environment/software.rs +++ b/src/environment/software.rs @@ -217,7 +217,8 @@ pub(crate) async fn reconcile( Ok(()) } -/// Coordinates one addon mutation. Owner configuration is saved separately. +/// Coordinates one addon mutation. Only Virgo uses automatic checkpoints; +/// Standard keeps direct writes. Owner configuration is saved separately. /// Failed shutdown/unmount returns before any rollback can touch live storage. async fn transact( storage: &mut Storage, @@ -234,19 +235,23 @@ async fn transact( block_size: prefix::FVS_BLOCK_SIZE, }; #[cfg(feature = "fvs")] - let checkpoint = { + let checkpoint = if matches!(storage, Storage::Virgo { .. }) { let stream = cx .fvs() .await? .commit_stream(&repository, prefix::AUTO_CHECKPOINT_MESSAGE.into()) .await?; - prefix::finish_commit(stream, |event| { - progress.send_replace(Some(Progress::transferring( - Stage::Checkpointing, - event.into(), - ))); - }) - .await? + Some( + prefix::finish_commit(stream, |event| { + progress.send_replace(Some(Progress::transferring( + Stage::Checkpointing, + event.into(), + ))); + }) + .await?, + ) + } else { + None }; let _ = progress; if cancellation.is_cancelled() { @@ -261,7 +266,7 @@ async fn transact( result }; #[cfg(feature = "fvs")] - if let Err(error) = &result { + if let (Err(error), Some(checkpoint)) = (&result, checkpoint) { let restored = async { let stream = cx .fvs() From f023ac3a12e992fbaf853405d90ed8ea7e01db43 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Fri, 11 Sep 2026 11:14:26 +0530 Subject: [PATCH 08/24] feat(core): build independent addon layers against pinned Soda --- README.md | 16 +- src/bottle/manager.rs | 2 + src/bottle/software.rs | 47 ++- src/core.rs | 13 + .../{prefix/virgo => artifacts}/cache.rs | 26 +- src/environment/artifacts/mod.rs | 357 ++++++++++++++++++ src/environment/artifacts/software.rs | 205 ++++++++++ src/environment/mod.rs | 39 ++ src/environment/prefix/mod.rs | 27 +- src/environment/prefix/virgo/mod.rs | 205 ++-------- src/environment/software.rs | 14 +- src/utils/context.rs | 9 + 12 files changed, 739 insertions(+), 221 deletions(-) rename src/environment/{prefix/virgo => artifacts}/cache.rs (93%) create mode 100644 src/environment/artifacts/mod.rs create mode 100644 src/environment/artifacts/software.rs diff --git a/README.md b/README.md index 56b9e42..aca0aa9 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,20 @@ New dependency selections can be appended. Prefix changes run before publication batch rollback of those effects remains part of the later composition work. Settings-only edits save the draft without preparing or mutating the prefix. -Pinned Soda builds and managed registry-baseline composition remain later steps. -Virgo retains its existing per-addon layer and checkpoint behavior. +Virgo builds a clean shared base from the latest catalog runner named `Soda` +(case-insensitive, semantic-version ordering). That exact release must already be +downloaded. The base manifest pins its release and immutable FVS revision across +catalog refreshes. `Bottles::rebuild_virgo_base()` explicitly publishes a new base; +stopped preparations adopt it, while running environments and snapshots retain +their resolved revisions. Older base generations and legacy caches are retained. + +Addon cache misses use pinned Soda and declared prerequisite layers, without +owner settings, wrappers, or private writable data. UUID remains the sole cache +identity: completed caches survive runner changes and base rebuilds. Runner +adapters are built using the selected runner over each base generation. Shared +construction is serialized within one core instance. Standard installers continue +using their owner's runner. Managed registry-baseline composition remains step 8; +Virgo retains its existing per-addon checkpoint boundary. Bottle configuration requires execution settings under `environment`, with resolved FVS layers retained inside `environment.storage` for Virgo. Old diff --git a/src/bottle/manager.rs b/src/bottle/manager.rs index 87a0d9d..3340362 100644 --- a/src/bottle/manager.rs +++ b/src/bottle/manager.rs @@ -219,6 +219,8 @@ impl BottleManager { loaded_runner.as_ref(), &runner_component.id().to_string(), &cx, + &addons, + &cancellation, ) .await?; let result = async { diff --git a/src/bottle/software.rs b/src/bottle/software.rs index bfa5da7..902b2a9 100644 --- a/src/bottle/software.rs +++ b/src/bottle/software.rs @@ -77,12 +77,7 @@ impl Bottle { } let state = bottle.state()?; let program = resolve(&state)?; - let environment = Environment::attach_or_start( - &state.environment, - bottle.0.cx.directories().bottle(state.id), - bottle.0.cx.clone(), - ) - .await?; + let environment = bottle.attach_or_start(&cancellation).await?; environment.launch_program(&program, &cancellation).await }) } @@ -194,18 +189,44 @@ impl Bottle { Environment::stop(&state.environment, &cx.directories().bottle(state.id), cx).await } + // Caller holds the owner lock. Attached runtimes keep their exact materialization. + async fn attach_or_start( + &self, + cancellation: &tokio_util::sync::CancellationToken, + ) -> Result { + let _ = cancellation; + let state = self.state()?; + let root = self.0.cx.directories().bottle(state.id); + if let Some(environment) = Environment::try_attach(&root).await? { + return Ok(environment); + } + #[cfg(feature = "fvs")] + { + let mut draft = state.as_ref().clone(); + if Environment::refresh_base( + &mut draft.environment, + &root, + &self.0.addons, + &self.0.cx, + cancellation, + ) + .await? + { + Self::save_state(&draft, &self.0.cx).await?; + self.publish(draft); + } + } + Environment::attach_or_start(&self.state()?.environment, root, self.0.cx.clone()).await + } + async fn with_environment(&self, work: F) -> Result where F: for<'a> AsyncFnOnce(&'a Environment) -> Result, { let _control = self.0.control.lock().await; - let state = self.state()?; - let environment = Environment::attach_or_start( - &state.environment, - self.0.cx.directories().bottle(state.id), - self.0.cx.clone(), - ) - .await?; + let environment = self + .attach_or_start(&tokio_util::sync::CancellationToken::new()) + .await?; work(&environment).await } } diff --git a/src/core.rs b/src/core.rs index 63a641e..7e056e7 100644 --- a/src/core.rs +++ b/src/core.rs @@ -64,6 +64,19 @@ impl Bottles { Ok(()) } + /// Rebuilds the shared Virgo base using the latest catalog Soda release. + /// That exact release must already be downloaded. Existing runtimes, snapshots, + /// and completed addon caches remain intact; stopped preparations adopt the base. + #[cfg(feature = "fvs")] + pub fn rebuild_virgo_base(&self) -> crate::Operation<()> { + let cx = self.context.clone(); + let addons = self.addons.clone(); + crate::Operation::new(move |progress, cancellation| async move { + progress.send_replace(Some(crate::Progress::new(crate::Stage::CreatingPrefix))); + crate::environment::artifacts::rebuild_base(&addons, &cx, &cancellation).await + }) + } + pub fn bottles(&self) -> &BottleManager { &self.bottles } diff --git a/src/environment/prefix/virgo/cache.rs b/src/environment/artifacts/cache.rs similarity index 93% rename from src/environment/prefix/virgo/cache.rs rename to src/environment/artifacts/cache.rs index fc5c7cf..ad83c98 100644 --- a/src/environment/prefix/virgo/cache.rs +++ b/src/environment/artifacts/cache.rs @@ -20,17 +20,16 @@ use crate::{ runner::Runner, }; -use super::super::FVS_BLOCK_SIZE; -use super::VirgoError; +use crate::environment::prefix::{FVS_BLOCK_SIZE, VirgoError}; /// Removes references without deleting a shared cache. -pub(super) fn remove(layers: &mut Vec, id: Uuid, context: &Context) { +pub(crate) fn remove(layers: &mut Vec, id: Uuid, context: &Context) { let repository = layer_path(id, context).display().to_string(); layers.retain(|layer| layer.repository_path != repository); } /// Checks only for FVS repository metadata; [`layer`] validates its commit. -pub(super) async fn exists(id: Uuid, context: &Context) -> Result { +pub(crate) async fn exists(id: Uuid, context: &Context) -> Result { let path = layer_path(id, context).join(".fvs2"); Ok(async_fs::metadata(path) .await @@ -48,9 +47,10 @@ pub(super) async fn exists(id: Uuid, context: &Context) -> Result { /// and filesystem destinations requires two renames and is not atomic as a pair; /// failure may therefore leave only one destination present. Staging cleanup is /// best-effort. -pub(super) async fn install( +pub(crate) async fn install( layers: Vec, item_id: Uuid, + prerequisites: &[Uuid], runner: &dyn Runner, execute: F, context: &Context, @@ -89,6 +89,9 @@ where let client = context.fvs().await?; let mount = client.mount(&prefix, layers, Some(&upper)).await?; let installed = async { + for id in prerequisites { + apply_registry(&prefix, *id, context).await?; + } for (file, _) in registry_files() { async_fs::copy(prefix.join(file), before.join(file)).await?; } @@ -151,12 +154,7 @@ where /// Both replacement hives are prepared in a scratch directory before either is /// installed, but the final renames are not atomic as a pair. Scratch cleanup is /// best-effort. -pub(super) async fn apply_registry( - root: &Path, - layers: &[Layer], - id: Uuid, - context: &Context, -) -> Result<()> { +pub(crate) async fn apply_registry(prefix: &Path, id: Uuid, context: &Context) -> Result<()> { let patches = registry_path(id, context); if !async_fs::metadata(&patches) .await @@ -165,9 +163,7 @@ pub(super) async fn apply_registry( return Ok(()); } - super::prepare(root, layers, context).await?; - let prefix = root.join("prefix"); - let apply_prefix = prefix.clone(); + let apply_prefix = prefix.to_path_buf(); let stage = prefix.join(format!(".bottles-next-registry-{}", Uuid::new_v4())); blocking::unblock(move || { fs::create_dir_all(&stage)?; @@ -195,7 +191,7 @@ pub(super) async fn apply_registry( /// Resolves a cached layer and its first available commit. /// /// Repository metadata without a commit is treated as a corrupt cache entry. -pub(super) async fn layer(id: Uuid, context: &Context) -> Result { +pub(crate) async fn layer(id: Uuid, context: &Context) -> Result { let destination = layer_path(id, context); if !async_fs::metadata(destination.join(".fvs2")) .await diff --git a/src/environment/artifacts/mod.rs b/src/environment/artifacts/mod.rs new file mode 100644 index 0000000..dfcb0ab --- /dev/null +++ b/src/environment/artifacts/mod.rs @@ -0,0 +1,357 @@ +//! Shared immutable bases, runner adapters, and UUID-only addon caches. + +pub(crate) mod cache; +mod software; +pub(crate) use software::prepare_addon; + +use super::prefix::{FVS_BLOCK_SIZE, VirgoError}; +use crate::{ + Addon, Addons, CatalogEntry, Component, Context, Slot, + error::{Error, Result}, + runner::Runner, +}; +use fvs_rs::{Layer, Repository, UnmountMode}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +#[derive(Deserialize, Serialize)] +struct Base { + soda: Addon, + layer: Layer, +} +impl next_config::Config for Base { + const VERSION: u32 = 1; +} + +fn manifest(cx: &Context) -> PathBuf { + cx.directories().data_dir().join("virgo/base.toml") +} + +fn latest_soda(entries: &[CatalogEntry]) -> Result<&CatalogEntry> { + let mut latest = None; + for entry in entries + .iter() + .filter(|entry| entry.slot() == Slot::Runner && entry.name().eq_ignore_ascii_case("soda")) + { + let version = semver::Version::parse(entry.version()) + .map_err(|_| VirgoError::InvalidSodaVersion(entry.version().into()))?; + if latest + .as_ref() + .is_none_or(|(current, _)| &version > current) + { + latest = Some((version, entry)); + } + } + latest + .map(|(_, entry)| entry) + .ok_or_else(|| VirgoError::SodaNotInCatalog.into()) +} + +fn downloaded_soda(soda: &Addon, addons: &Addons) -> Result<()> { + if !addons + .component(soda.id()) + .is_some_and(|entry| entry.slot() == Slot::Runner && entry.version() == soda.version()) + { + return Err(VirgoError::SodaNotDownloaded { + id: soda.id(), + version: soda.version().into(), + } + .into()); + } + Ok(()) +} + +// Caller holds the shared build mutex, including publication of the manifest. +async fn ensure_base( + addons: &Addons, + cx: &Context, + cancellation: &CancellationToken, +) -> Result { + if crate::utils::exists(&manifest(cx)).await? { + return Ok(next_config::load(manifest(cx)).await?); + } + build_base(addons, cx, cancellation).await +} + +pub(crate) async fn rebuild_base( + addons: &Addons, + cx: &Context, + cancellation: &CancellationToken, +) -> Result<()> { + let _build = cancellation + .run_until_cancelled(cx.artifact_build().lock()) + .await + .ok_or(Error::Cancelled)?; + build_base(addons, cx, cancellation).await?; + Ok(()) +} + +async fn build_base( + addons: &Addons, + cx: &Context, + cancellation: &CancellationToken, +) -> Result { + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let entries = addons.component_entries(); + let selected = latest_soda(&entries)?; + let downloaded = addons + .component(selected.id()) + .filter(|entry| entry.slot() == Slot::Runner && entry.version() == selected.version()) + .ok_or_else(|| VirgoError::SodaNotDownloaded { + id: selected.id(), + version: selected.version().into(), + })?; + let soda = Addon::from(downloaded.as_ref()); + let runner = soda.load_runner(cx.directories(), None).await?; + let generation = cx + .directories() + .data_dir() + .join("virgo/bases") + .join(Uuid::new_v4().to_string()); + let prefix = generation.join("prefix"); + async_fs::create_dir_all(&prefix).await?; + let initialized = runner.wineboot(&prefix, "--init").await; + // Keep storage if Wine cannot be stopped safely. + super::shutdown_wine(runner.as_ref(), &prefix).await?; + let result = async { + initialized?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let client = cx.fvs().await?; + let repository = client.new_repository(&prefix, FVS_BLOCK_SIZE).await?; + let commit = client + .commit( + &repository, + format!("Soda {} ({})", soda.version(), soda.id()), + ) + .await?; + let base = Base { + soda, + layer: Layer::new(&repository, Some(&commit)), + }; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + next_config::save(manifest(cx), &base).await?; + Ok::<_, Error>(base) + } + .await; + if result.is_err() { + remove_dir(generation).await; + } + result +} + +pub(crate) async fn base_layers( + runner: &dyn Runner, + runner_key: &str, + addons: &Addons, + cx: &Context, + cancellation: &CancellationToken, +) -> Result> { + let _build = cancellation + .run_until_cancelled(cx.artifact_build().lock()) + .await + .ok_or(Error::Cancelled)?; + let base = ensure_base(addons, cx, cancellation).await?; + let adapter = ensure_adapter(runner, runner_key, &base.layer, cx, cancellation).await?; + Ok(vec![base.layer, adapter]) +} + +/// Loads or creates the runner adapter within this immutable base generation. +/// +/// Creation is staged over the shared base and published by renaming the +/// committed upper directory into the adapter cache. +async fn ensure_adapter( + runner: &dyn Runner, + runner_key: &str, + base: &Layer, + context: &Context, + cancellation: &CancellationToken, +) -> Result { + let root = Path::new(&base.repository_path).with_file_name("adapters"); + let destination = root.join(runner_key); + if async_fs::metadata(destination.join(".fvs2")) + .await + .is_ok_and(|entry| entry.is_dir()) + { + let client = context.fvs().await?; + let repository = client.new_repository(&destination, 0).await?; + let commit = client + .list_commits(&repository) + .await? + .into_iter() + .next() + .ok_or_else(|| VirgoError::MissingCommit { + repository: destination.clone(), + state: "HEAD".into(), + })?; + return Ok(Layer::from_summary(&repository, Some(&commit))); + } + + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let stage = context + .directories() + .data_dir() + .join("virgo/.staging") + .join(Uuid::new_v4().to_string()); + let upper = stage.join("upper"); + let mountpoint = stage.join("prefix"); + async_fs::create_dir_all(&upper).await?; + async_fs::create_dir_all(&mountpoint).await?; + + let client = context.fvs().await?; + let mount = client + .mount(&mountpoint, vec![base.clone()], Some(&upper)) + .await?; + let initialized = runner.wineboot(&mountpoint, "--init").await; + crate::environment::shutdown_wine(runner, &mountpoint).await?; + client + .unmount(&mount, UnmountMode::Normal) + .await + .map_err(|source| crate::EnvironmentError::Cleanup { + prefix: mountpoint.clone(), + source: Box::new(source.into()), + })?; + let build = async { + initialized?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + + let client = context.fvs().await?; + let repository = client.new_repository(&upper, FVS_BLOCK_SIZE).await?; + let commit = client + .commit(&repository, format!("Runner adapter {runner_key}")) + .await?; + async_fs::create_dir_all(root).await?; + async_fs::rename(&upper, &destination).await?; + Ok::<_, Error>(commit) + } + .await; + remove_dir(stage).await; + + let commit = build?; + let repository = Repository { + repository_path: destination.display().to_string(), + block_size: FVS_BLOCK_SIZE, + }; + Ok(Layer::new(&repository, Some(&commit))) +} + +async fn remove_dir(path: PathBuf) { + let _ = async_fs::remove_dir_all(path).await; +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn release(name: &str, version: &str) -> CatalogEntry { + serde_json::from_value(json!({ + "id": Uuid::new_v4(), "name": name, "version": version, "slot": "runner", + "artifacts": [{"url": "https://example.invalid/soda.tar", "file_name": "soda.tar", + "checksum": {"algorithm": "sha256", "value": "unused"}}] + })) + .unwrap() + } + + #[tokio::test] + async fn soda_selection_pinning_and_uuid_reuse_without_services() { + let entries = vec![ + release("SODA", "2.9.0"), + release("Soda", "2.10.0"), + release("Wine", "99.0.0"), + ]; + assert_eq!(latest_soda(&entries).unwrap().id(), entries[1].id()); + assert!(matches!( + latest_soda(&[release("Soda", "invalid")]), + Err(Error::Virgo(VirgoError::InvalidSodaVersion(_))) + )); + let root = std::env::temp_dir().join(format!("soda-test-{}", Uuid::new_v4())); + let cx = Context::for_test(crate::Directories::from_path(&root).unwrap(), None).unwrap(); + async_fs::write( + cx.directories().components().join("catalog.json"), + serde_json::to_vec(&json!({"schema_version": 1, "entries": entries})).unwrap(), + ) + .await + .unwrap(); + let addons = Addons::load(cx.clone(), None, None).await.unwrap(); + let cancellation = CancellationToken::new(); + assert!( + matches!(rebuild_base(&addons, &cx, &cancellation).await, Err(Error::Virgo(VirgoError::SodaNotDownloaded { id, .. })) if id == entries[1].id()) + ); + + let soda = serde_json::from_value( + json!({"id": entries[0].id(), "name": "Soda", "version": "2.9.0", "slot": "runner"}), + ) + .unwrap(); + let layer = Layer::new( + &Repository { + repository_path: root.join("old-base").display().to_string(), + block_size: FVS_BLOCK_SIZE, + }, + Some(&fvs_rs::Commit { + state_id: "pinned".into(), + ..Default::default() + }), + ); + next_config::save( + manifest(&cx), + &Base { + soda, + layer: layer.clone(), + }, + ) + .await + .unwrap(); + let pinned = ensure_base(&addons, &cx, &cancellation).await.unwrap(); + assert_eq!(pinned.soda.id(), entries[0].id()); + assert_eq!(pinned.layer, layer); + assert!(rebuild_base(&addons, &cx, &cancellation).await.is_err()); + assert_eq!( + ensure_base(&addons, &cx, &cancellation) + .await + .unwrap() + .layer, + layer + ); + + let id = Uuid::new_v4(); + async_fs::create_dir_all( + cx.directories() + .data_dir() + .join("virgo/layers") + .join(id.to_string()) + .join(".fvs2"), + ) + .await + .unwrap(); + let mut config = crate::EnvironmentConfig { + storage: crate::Storage::Virgo { layers: vec![] }, + components: Default::default(), + dependencies: vec![], + env_vars: Default::default(), + wrappers: Default::default(), + }; + let (progress, _) = tokio::sync::watch::channel(None); + prepare_addon(id, &config, &addons, &cx, &progress, &cancellation) + .await + .unwrap(); + config.components.insert(Slot::Runner, pinned.soda); + prepare_addon(id, &config, &addons, &cx, &progress, &cancellation) + .await + .unwrap(); + drop(addons); + drop(cx); + async_fs::remove_dir_all(root).await.unwrap(); + } +} diff --git a/src/environment/artifacts/software.rs b/src/environment/artifacts/software.rs new file mode 100644 index 0000000..99ef57c --- /dev/null +++ b/src/environment/artifacts/software.rs @@ -0,0 +1,205 @@ +//! Builds only declared prerequisites over Soda, without owner settings or upper data. + +use strum::IntoEnumIterator; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::{cache, downloaded_soda, ensure_base}; +use crate::{ + AddonError, Addons, Context, EnvVars, EnvironmentError, Progress, Requirement, Slot, Stage, + addons::{Artifact, InstallInputs, execute, replay_env_vars}, + environment::{EnvironmentConfig, prefix::VirgoError}, + error::{Error, Result}, +}; + +fn requirements(id: Uuid, config: &EnvironmentConfig) -> Result<&[Requirement]> { + if let Some(addon) = config.components.values().find(|addon| addon.id() == id) { + return Ok(addon.requirements()); + } + Ok(config + .dependency(id) + .ok_or(AddonError::NotFound(id))? + .requirements()) +} + +/// Runtime requirements supply tools, not layers. Soda always supplies Wine. +fn prerequisites(id: Uuid, config: &EnvironmentConfig) -> Result> { + let mut ids = Vec::new(); + for requirement in requirements(id, config)? { + if let Some(addon) = Slot::iter() + .filter_map(|slot| config.component(slot)) + .find(|addon| addon.satisfies(requirement)) + { + if !addon.slot().is_runtime() { + ids.push(addon.id()); + } + } else if let Some(addon) = config + .dependencies + .iter() + .find(|addon| addon.satisfies(requirement)) + { + ids.push(addon.id()); + } else { + return Err(EnvironmentError::RequiresAddon { + required_by: Some(id), + requirements: vec![requirement.clone()], + } + .into()); + } + } + Ok(ids) +} + +fn visit( + id: Uuid, + config: &EnvironmentConfig, + visiting: &mut Vec, + ordered: &mut Vec, +) -> Result<()> { + if ordered.contains(&id) { + return Ok(()); + } + if visiting.contains(&id) { + return Err(VirgoError::CyclicPrerequisites(id).into()); + } + visiting.push(id); + for prerequisite in prerequisites(id, config)? { + visit(prerequisite, config, visiting, ordered)?; + } + visiting.pop(); + ordered.push(id); + Ok(()) +} + +fn resources(id: Uuid, addons: &Addons, cx: &Context) -> Result> { + if let Some(component) = addons.component(id) { + return Ok(vec![component.artifact(cx.directories())]); + } + let dependency = addons.dependency(id).ok_or(AddonError::NotFound(id))?; + Ok(dependency + .artifacts() + .iter() + .map(|artifact| { + Artifact::new( + dependency.path(cx.directories()).join(&artifact.path), + artifact.steps.clone(), + ) + }) + .collect()) +} + +pub(crate) async fn prepare_addon( + id: Uuid, + config: &EnvironmentConfig, + addons: &Addons, + cx: &Context, + progress: &watch::Sender>, + cancellation: &CancellationToken, +) -> Result<()> { + let _build = cancellation + .run_until_cancelled(cx.artifact_build().lock()) + .await + .ok_or(Error::Cancelled)?; + // UUID alone is the cache identity, even across base rebuilds and settings changes. + if cache::exists(id, cx).await? { + return Ok(()); + } + let base = ensure_base(addons, cx, cancellation).await?; + downloaded_soda(&base.soda, addons)?; + let runner = base.soda.load_runner(cx.directories(), None).await?; + let winebridge = addons + .latest_component(Slot::WineBridge) + .ok_or(EnvironmentError::ComponentNotInstalled(Slot::WineBridge))? + .path(cx.directories()); + let mut order = Vec::new(); + visit(id, config, &mut Vec::new(), &mut order)?; + for id in order { + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + if cache::exists(id, cx).await? { + continue; + } + let mut required = Vec::new(); + visit(id, config, &mut Vec::new(), &mut required)?; + required.pop(); + let mut layers = vec![base.layer.clone()]; + let mut env_vars = EnvVars::default(); + for prerequisite in &required { + layers.push(cache::layer(*prerequisite, cx).await?); + replay_env_vars(&mut env_vars, &resources(*prerequisite, addons, cx)?); + } + let resources = resources(id, addons, cx)?; + cache::install( + layers, + id, + &required, + runner.as_ref(), + async |prefix| { + execute( + InstallInputs { + prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars: &mut env_vars, + }, + &resources, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Configuring))); + }, + ) + .await + }, + cx, + ) + .await?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn prerequisite_order_excludes_unrelated_addons_and_rejects_cycles() { + let ids = [ + Uuid::new_v4(), + Uuid::new_v4(), + Uuid::new_v4(), + Uuid::new_v4(), + ]; + let dependency = |id: Uuid, requirements: Vec| { + serde_json::from_value(json!({ + "id": id, "name": id.to_string(), "version": "1.0.0", "requirements": requirements + })) + .unwrap() + }; + let mut config = EnvironmentConfig { + storage: crate::Storage::Virgo { layers: vec![] }, + components: Default::default(), + dependencies: vec![ + dependency( + ids[0], + vec![Requirement::Id(ids[1]), Requirement::Id(ids[2])], + ), + dependency(ids[1], vec![Requirement::Id(ids[2])]), + dependency(ids[2], vec![]), + dependency(ids[3], vec![]), + ], + env_vars: Default::default(), + wrappers: Default::default(), + }; + let mut order = Vec::new(); + visit(ids[0], &config, &mut Vec::new(), &mut order).unwrap(); + assert_eq!(order, [ids[2], ids[1], ids[0]]); + config.dependencies[2] = dependency(ids[2], vec![Requirement::Id(ids[0])]); + assert!(matches!( + visit(ids[0], &config, &mut Vec::new(), &mut Vec::new()), + Err(Error::Virgo(VirgoError::CyclicPrerequisites(_))) + )); + } +} diff --git a/src/environment/mod.rs b/src/environment/mod.rs index cff9cb7..45f6008 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -1,5 +1,8 @@ //! Shared execution configuration and temporary connections to a running environment. +#[cfg(feature = "fvs")] +pub(crate) mod artifacts; + mod config; mod error; pub(crate) mod prefix; @@ -31,6 +34,42 @@ pub(crate) struct Environment { } impl Environment { + /// Refreshes only the shared base and runner adapter while the owner is stopped. + /// The owner saves changed materialization references before starting Wine. + #[cfg(feature = "fvs")] + pub(crate) async fn refresh_base( + config: &mut EnvironmentConfig, + root: &Path, + addons: &crate::Addons, + cx: &Context, + cancellation: &CancellationToken, + ) -> Result { + let Storage::Virgo { layers } = &config.storage else { + return Ok(false); + }; + let runner = config + .runner() + .load_runner(cx.directories(), config.umu()) + .await?; + let base = artifacts::base_layers( + runner.as_ref(), + &config.runner().id().to_string(), + addons, + cx, + cancellation, + ) + .await?; + if layers.starts_with(&base) { + return Ok(false); + } + Self::stop(config, root, cx).await?; + let Storage::Virgo { layers } = &mut config.storage else { + unreachable!() + }; + layers.splice(..layers.len().min(2), base); + Ok(true) + } + /// Stops Wine and unmounts storage without requiring a live handle or bridge. pub(crate) async fn stop(config: &EnvironmentConfig, root: &Path, cx: &Context) -> Result<()> { let runner = config diff --git a/src/environment/prefix/mod.rs b/src/environment/prefix/mod.rs index 493d1d2..7d3c1ac 100644 --- a/src/environment/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -71,14 +71,17 @@ pub(crate) async fn create( runner: &dyn Runner, runner_key: &str, context: &Context, + addons: &crate::Addons, + cancellation: &tokio_util::sync::CancellationToken, ) -> Result<()> { #[cfg(not(feature = "fvs"))] - let _ = (runner_key, context); + let _ = (runner_key, context, addons, cancellation); match storage { Storage::Standard => standard::create(&root.join("prefix"), runner).await, #[cfg(feature = "fvs")] Storage::Virgo { layers } => { - *layers = virgo::create(root, runner, runner_key, context).await?; + *layers = + virgo::create(root, runner, runner_key, context, addons, cancellation).await?; Ok(()) } } @@ -108,15 +111,26 @@ pub(crate) async fn rebuild( runner_key: &str, installed: &[Uuid], context: &Context, + addons: &crate::Addons, + cancellation: &tokio_util::sync::CancellationToken, ) -> Result<()> { match storage { Storage::Standard => { - let _ = (runner, runner_key, installed, context); + let _ = (runner, runner_key, installed, context, addons, cancellation); Ok(()) } #[cfg(feature = "fvs")] Storage::Virgo { layers } => { - virgo::rebuild(layers, runner, runner_key, installed, context).await + virgo::rebuild( + layers, + runner, + runner_key, + installed, + context, + addons, + cancellation, + ) + .await } } } @@ -126,17 +140,16 @@ pub(crate) async fn install( storage: &mut Storage, root: &Path, item_id: Uuid, - runner: &dyn Runner, replaced_id: Option, execute: impl for<'a> std::ops::AsyncFnOnce(&'a Path) -> Result<()>, context: &Context, ) -> Result<()> { - let _ = (item_id, runner, replaced_id, context); + let _ = (item_id, replaced_id, context); match storage { Storage::Standard => execute(&root.join("prefix")).await, #[cfg(feature = "fvs")] Storage::Virgo { layers } => { - virgo::install(root, layers, item_id, runner, replaced_id, execute, context).await + virgo::install(root, layers, item_id, replaced_id, context).await } } } diff --git a/src/environment/prefix/virgo/mod.rs b/src/environment/prefix/virgo/mod.rs index c13135b..26a352d 100644 --- a/src/environment/prefix/virgo/mod.rs +++ b/src/environment/prefix/virgo/mod.rs @@ -5,7 +5,7 @@ //! persisted by the owner and must be changed only while the owner is //! stopped. -mod cache; +use crate::environment::artifacts::{self, cache}; use std::{ ops::AsyncFnOnce, @@ -13,16 +13,10 @@ use std::{ }; use futures_lite::StreamExt; -use fvs_rs::{Layer, Repository, UnmountMode}; +use fvs_rs::{Layer, UnmountMode}; use uuid::Uuid; -use crate::{ - Context, - error::{Error, Result}, - runner::Runner, -}; - -use super::FVS_BLOCK_SIZE; +use crate::{Context, error::Result, runner::Runner}; /// Virgo-specific failures carried by [`crate::error::Error::Virgo`]. #[derive(Debug, thiserror::Error)] @@ -35,12 +29,14 @@ pub enum VirgoError { /// Requested full or abbreviated state ID. state: String, }, - /// An existing Virgo base repository has no commits to use as a layer. - #[error("Virgo base exists but has no commits")] - EmptyBase, - /// Virgo cannot initialize a base over an existing nonempty directory. - #[error("refusing to initialize non-empty Virgo base at {0}")] - DirtyBase(PathBuf), + #[error("no Soda runner release in the current component catalog")] + SodaNotInCatalog, + #[error("invalid Soda semantic version: {0}")] + InvalidSodaVersion(String), + #[error("download Soda {version} ({id}) before building the Virgo base or an addon layer")] + SodaNotDownloaded { id: Uuid, version: String }, + #[error("cyclic addon prerequisites involving {0}")] + CyclicPrerequisites(Uuid), /// Virgo cannot mount a prefix over a nonempty mountpoint. #[error("mountpoint is not empty: {0}")] DirtyMountpoint(PathBuf), @@ -61,10 +57,12 @@ pub(super) async fn create( runner: &dyn Runner, runner_key: &str, context: &Context, + addons: &crate::Addons, + cancellation: &tokio_util::sync::CancellationToken, ) -> Result> { let upper = root.join("upper"); async_fs::create_dir_all(upper).await?; - base_layers(runner, runner_key, context).await + artifacts::base_layers(runner, runner_key, addons, context, cancellation).await } pub(super) async fn prepare(root: &Path, layers: &[Layer], context: &Context) -> Result<()> { @@ -124,10 +122,13 @@ pub(super) async fn rebuild( runner_key: &str, installed: &[Uuid], context: &Context, + addons: &crate::Addons, + cancellation: &tokio_util::sync::CancellationToken, ) -> Result<()> { // Build separately so failure to resolve any cached addon does not partially // replace the owner's persisted layer order. - let mut rebuilt = base_layers(runner, runner_key, context).await?; + let mut rebuilt = + artifacts::base_layers(runner, runner_key, addons, context, cancellation).await?; for id in installed { rebuilt.push(cache::layer(*id, context).await?); } @@ -135,31 +136,21 @@ pub(super) async fn rebuild( Ok(()) } -pub(super) async fn install( +pub(super) async fn install( root: &Path, layers: &mut Vec, item_id: Uuid, - runner: &dyn Runner, replaced_id: Option, - execute: F, context: &Context, -) -> Result<()> -where - F: for<'a> AsyncFnOnce(&'a Path) -> Result<()>, -{ - // A cache hit deliberately skips the recipe. The cached filesystem layer and - // registry patch must therefore capture every prefix effect of installation. - if !cache::exists(item_id, context).await? { - cache::install(layers.clone(), item_id, runner, execute, context).await?; - } - +) -> Result<()> { let cached = cache::layer(item_id, context).await?; if let Some(id) = replaced_id { cache::remove(layers, id, context); } cache::remove(layers, item_id, context); layers.push(cached); - cache::apply_registry(root, layers, item_id, context).await + prepare(root, layers, context).await?; + cache::apply_registry(&root.join("prefix"), item_id, context).await } pub(super) async fn uninstall( @@ -180,154 +171,6 @@ where execute(&root.join("prefix"), false).await } -async fn base_layers( - runner: &dyn Runner, - runner_key: &str, - context: &Context, -) -> Result> { - let base = ensure_base(runner, context).await?; - let adapter = ensure_adapter(runner, runner_key, &base, context).await?; - Ok(vec![base, adapter]) -} - -/// Loads or creates the single base shared by every Virgo owner. -/// -/// Once the base repository exists, `runner` is not used. A nonempty directory -/// without an FVS repository is rejected rather than overwritten. -async fn ensure_base(runner: &dyn Runner, context: &Context) -> Result { - let base_path = context.directories().data_dir().join("virgo/base"); - let repository_path = base_path.join("prefix"); - let cached = if async_fs::metadata(repository_path.join(".fvs2")) - .await - .is_ok_and(|entry| entry.is_dir()) - { - true - } else { - if crate::utils::exists(&repository_path).await? - && async_fs::read_dir(&repository_path) - .await? - .try_next() - .await? - .is_some() - { - return Err(VirgoError::DirtyBase(repository_path).into()); - } - async_fs::create_dir_all(&repository_path).await?; - false - }; - - let client = context.fvs().await?; - if cached { - let repository = client.new_repository(&repository_path, 0).await?; - let commit = client - .list_commits(&repository) - .await? - .into_iter() - .next() - .ok_or(VirgoError::EmptyBase)?; - return Ok(Layer::from_summary(&repository, Some(&commit))); - } - - let initialized = runner.wineboot(&repository_path, "--init").await; - crate::environment::shutdown_wine(runner, &repository_path).await?; - if let Err(error) = initialized { - remove_dir(base_path).await; - return Err(error); - } - let committed = async { - let repository = client - .new_repository(&repository_path, FVS_BLOCK_SIZE) - .await?; - let commit = client.commit(&repository, "Virgo base".into()).await?; - Ok(Layer::new(&repository, Some(&commit))) - } - .await; - if committed.is_err() { - remove_dir(base_path).await; - } - committed -} - -/// Loads or creates the adapter cache identified solely by `runner_key`. -/// -/// Creation is staged over the shared base and published by renaming the -/// committed upper directory into the adapter cache. -async fn ensure_adapter( - runner: &dyn Runner, - runner_key: &str, - base: &Layer, - context: &Context, -) -> Result { - let root = adapter_root(context); - let destination = root.join(runner_key); - if async_fs::metadata(destination.join(".fvs2")) - .await - .is_ok_and(|entry| entry.is_dir()) - { - let client = context.fvs().await?; - let repository = client.new_repository(&destination, 0).await?; - let commit = client - .list_commits(&repository) - .await? - .into_iter() - .next() - .ok_or_else(|| VirgoError::MissingCommit { - repository: destination.clone(), - state: "HEAD".into(), - })?; - return Ok(Layer::from_summary(&repository, Some(&commit))); - } - - let stage = context - .directories() - .data_dir() - .join("virgo/.staging") - .join(Uuid::new_v4().to_string()); - let upper = stage.join("upper"); - let mountpoint = stage.join("prefix"); - async_fs::create_dir_all(&upper).await?; - async_fs::create_dir_all(&mountpoint).await?; - - let client = context.fvs().await?; - let mount = client - .mount(&mountpoint, vec![base.clone()], Some(&upper)) - .await?; - let initialized = runner.wineboot(&mountpoint, "--init").await; - crate::environment::shutdown_wine(runner, &mountpoint).await?; - client - .unmount(&mount, UnmountMode::Normal) - .await - .map_err(|source| crate::EnvironmentError::Cleanup { - prefix: mountpoint.clone(), - source: Box::new(source.into()), - })?; - let build = async { - initialized?; - - let client = context.fvs().await?; - let repository = client.new_repository(&upper, FVS_BLOCK_SIZE).await?; - let commit = client - .commit(&repository, format!("Runner adapter {runner_key}")) - .await?; - async_fs::create_dir_all(root).await?; - async_fs::rename(&upper, &destination).await?; - Ok::<_, Error>(commit) - } - .await; - remove_dir(stage).await; - - let commit = build?; - let repository = Repository { - repository_path: destination.display().to_string(), - block_size: FVS_BLOCK_SIZE, - }; - Ok(Layer::new(&repository, Some(&commit))) -} - -fn adapter_root(context: &Context) -> PathBuf { - context.directories().data_dir().join("virgo/adapters") -} - /// Refuses to mount over existing contents, which would otherwise be hidden. async fn ensure_empty_dir(path: &Path) -> Result<()> { async_fs::create_dir_all(path).await?; @@ -336,7 +179,3 @@ async fn ensure_empty_dir(path: &Path) -> Result<()> { } Ok(()) } - -async fn remove_dir(path: PathBuf) { - let _ = async_fs::remove_dir_all(path).await; -} diff --git a/src/environment/software.rs b/src/environment/software.rs index 7e2ddc7..1b0f611 100644 --- a/src/environment/software.rs +++ b/src/environment/software.rs @@ -113,6 +113,17 @@ pub(crate) async fn reconcile( .runner() .load_runner(cx.directories(), candidate.umu()) .await?; + #[cfg(feature = "fvs")] + if matches!(candidate.storage, Storage::Virgo { .. }) { + for (id, _, _) in &installations { + super::artifacts::prepare_addon(*id, candidate, addons, cx, progress, cancellation) + .await?; + } + } + #[cfg(feature = "fvs")] + if !runner_changed { + super::Environment::refresh_base(candidate, root, addons, cx, cancellation).await?; + } let winebridge = candidate.winebridge().path(cx.directories()); let env_vars = &mut candidate.env_vars; @@ -172,6 +183,8 @@ pub(crate) async fn reconcile( &candidate.components[&Slot::Runner].id().to_string(), &installed, cx, + addons, + cancellation, ) .await?; } @@ -188,7 +201,6 @@ pub(crate) async fn reconcile( storage, root, id, - runner.as_ref(), replaced, async |prefix| { execute( diff --git a/src/utils/context.rs b/src/utils/context.rs index 2e67fdd..31a5e2c 100644 --- a/src/utils/context.rs +++ b/src/utils/context.rs @@ -13,6 +13,8 @@ struct ContextInner { fvs2d_executable: PathBuf, #[cfg(feature = "fvs")] fvs: OnceCell, + #[cfg(feature = "fvs")] + artifact_build: tokio::sync::Mutex<()>, } #[derive(Clone)] @@ -41,6 +43,8 @@ impl Context { .unwrap_or_else(|| PathBuf::from("fvs2d")), #[cfg(feature = "fvs")] fvs: OnceCell::new(), + #[cfg(feature = "fvs")] + artifact_build: tokio::sync::Mutex::new(()), }))) } @@ -67,6 +71,11 @@ impl Context { &self.0.http_client } + #[cfg(feature = "fvs")] + pub(crate) fn artifact_build(&self) -> &tokio::sync::Mutex<()> { + &self.0.artifact_build + } + #[cfg(feature = "fvs")] pub(crate) async fn fvs(&self) -> Result<&Fvs2dClient> { self.0 From 9ffcebb5e965e634e957d3fdae006be7b5b3958f Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Fri, 11 Sep 2026 12:13:25 +0530 Subject: [PATCH 09/24] fix(core): simplify environment preparation and pin Virgo base --- README.md | 11 +-- src/addons/index/mod.rs | 9 +++ src/addons/mod.rs | 4 +- src/bottle/manager.rs | 61 ++++++++-------- src/bottle/software.rs | 27 +++---- src/core.rs | 13 ---- src/environment/artifacts/mod.rs | 100 +++++++++----------------- src/environment/artifacts/software.rs | 23 ++---- src/environment/error.rs | 13 ++++ src/environment/mod.rs | 86 ++++++++++------------ src/environment/prefix/mod.rs | 58 ++------------- src/environment/prefix/standard.rs | 15 ---- src/environment/prefix/virgo/mod.rs | 45 +----------- src/environment/software.rs | 79 ++++++-------------- 14 files changed, 178 insertions(+), 366 deletions(-) delete mode 100644 src/environment/prefix/standard.rs diff --git a/README.md b/README.md index aca0aa9..fcda2d0 100644 --- a/README.md +++ b/README.md @@ -66,14 +66,15 @@ Settings-only edits save the draft without preparing or mutating the prefix. Virgo builds a clean shared base from the latest catalog runner named `Soda` (case-insensitive, semantic-version ordering). That exact release must already be downloaded. The base manifest pins its release and immutable FVS revision across -catalog refreshes. `Bottles::rebuild_virgo_base()` explicitly publishes a new base; -stopped preparations adopt it, while running environments and snapshots retain -their resolved revisions. Older base generations and legacy caches are retained. +catalog refreshes. The base is initialized once and reused; there is no rebuild +operation. Startup mounts the owner's saved layers directly; creation and runner +changes resolve the base and adapter references. New bases and adapters use +`virgo/soda`; existing manifest references and addon caches remain usable. Addon cache misses use pinned Soda and declared prerequisite layers, without owner settings, wrappers, or private writable data. UUID remains the sole cache -identity: completed caches survive runner changes and base rebuilds. Runner -adapters are built using the selected runner over each base generation. Shared +identity: completed caches survive runner and settings changes. Runner +adapters are built using the selected runner over the pinned base. Shared construction is serialized within one core instance. Standard installers continue using their owner's runner. Managed registry-baseline composition remains step 8; Virgo retains its existing per-addon checkpoint boundary. diff --git a/src/addons/index/mod.rs b/src/addons/index/mod.rs index 196efe5..669a943 100644 --- a/src/addons/index/mod.rs +++ b/src/addons/index/mod.rs @@ -195,6 +195,15 @@ impl IndexEntry { ) } + /// Resolves dependency recipe resources against their downloaded directory. + pub(crate) fn resources(&self, directories: &Directories) -> Vec { + let root = self.path(directories); + self.artifacts + .iter() + .map(|artifact| Artifact::new(root.join(&artifact.path), artifact.steps.clone())) + .collect() + } + pub(crate) fn artifacts(&self) -> &[Artifact] { &self.artifacts } diff --git a/src/addons/mod.rs b/src/addons/mod.rs index 42878c9..5ebc509 100644 --- a/src/addons/mod.rs +++ b/src/addons/mod.rs @@ -30,7 +30,9 @@ pub use catalog::CatalogEntry; pub(crate) use catalog::Checksum; pub use error::{AddonError, CatalogError, InstallerError}; pub use index::IndexEntry; -pub(crate) use installer::{Artifact, InstallInputs, execute, replay_env_vars, uninstall}; +#[cfg(feature = "fvs")] +pub(crate) use installer::Artifact; +pub(crate) use installer::{InstallInputs, execute, replay_env_vars, uninstall}; pub use manager::Addons; /// Rejects empty or whitespace-only input without trimming accepted values. diff --git a/src/bottle/manager.rs b/src/bottle/manager.rs index 3340362..59e9d72 100644 --- a/src/bottle/manager.rs +++ b/src/bottle/manager.rs @@ -21,7 +21,7 @@ use crate::environment::prefix::FVS_BLOCK_SIZE; use crate::{ Context, EnvironmentConfig, EnvironmentError, Operation, Progress, Stage, Storage, addons::{Addon, Addons, Requirement, Slot}, - environment::prefix, + environment::{Environment, prefix}, error::{Error, Result}, }; @@ -160,7 +160,7 @@ impl BottleManager { pub fn create( &self, name: impl Into, - mut storage: Storage, + storage: Storage, runner: Uuid, ) -> Operation { let name = name.into(); @@ -211,44 +211,43 @@ impl BottleManager { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - fs::create_dir_all(&bottle_path).await?; - // Creation may retain live storage on failure; keep it outside the removal path. - prefix::create( - &mut storage, - &bottle_path, + let mut components = HashMap::from([ + (Slot::WineBridge, Addon::from(winebridge.as_ref())), + (Slot::Runner, Addon::from(runner_component.as_ref())), + ]); + if let Some(umu) = umu { + components.insert(Slot::Umu, Addon::from(umu.as_ref())); + } + let mut config = EnvironmentConfig { + storage, + components, + dependencies: Vec::new(), + env_vars: Default::default(), + wrappers: Default::default(), + }; + #[cfg(feature = "fvs")] + if let Storage::Virgo { layers } = &mut config.storage { + layers.clear(); + } + Environment::prepare( + &mut config, loaded_runner.as_ref(), - &runner_component.id().to_string(), - &cx, &addons, + &cx, &cancellation, ) .await?; + prefix::create(&config.storage, &bottle_path).await?; + // Initialization may retain live storage on failure; keep it outside the removal path. + if matches!(config.storage, Storage::Standard) { + Environment::initialize(loaded_runner.as_ref(), &bottle_path.join("prefix")) + .await?; + } let result = async { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - - let mut components = HashMap::from([ - (Slot::WineBridge, Addon::from(winebridge.as_ref())), - (Slot::Runner, Addon::from(runner_component.as_ref())), - ]); - if let Some(umu) = umu { - components.insert(Slot::Umu, Addon::from(umu.as_ref())); - } - let bottle = Bottle::new( - id, - name, - EnvironmentConfig { - storage, - components, - dependencies: Vec::new(), - env_vars: Default::default(), - wrappers: Default::default(), - }, - cx.clone(), - addons.clone(), - ) - .await?; + let bottle = Bottle::new(id, name, config, cx.clone(), addons.clone()).await?; progress.send_replace(Some(Progress::new(Stage::Configuring))); #[cfg(feature = "fvs")] if matches!(&bottle.state()?.environment.storage, Storage::Virgo { .. }) { diff --git a/src/bottle/software.rs b/src/bottle/software.rs index 902b2a9..29b804c 100644 --- a/src/bottle/software.rs +++ b/src/bottle/software.rs @@ -189,34 +189,25 @@ impl Bottle { Environment::stop(&state.environment, &cx.directories().bottle(state.id), cx).await } - // Caller holds the owner lock. Attached runtimes keep their exact materialization. + // Caller holds the owner lock. Startup uses the saved layer references. async fn attach_or_start( &self, cancellation: &tokio_util::sync::CancellationToken, ) -> Result { - let _ = cancellation; let state = self.state()?; let root = self.0.cx.directories().bottle(state.id); if let Some(environment) = Environment::try_attach(&root).await? { return Ok(environment); } - #[cfg(feature = "fvs")] - { - let mut draft = state.as_ref().clone(); - if Environment::refresh_base( - &mut draft.environment, - &root, - &self.0.addons, - &self.0.cx, - cancellation, - ) - .await? - { - Self::save_state(&draft, &self.0.cx).await?; - self.publish(draft); - } + let runner = state + .environment + .runner() + .load_runner(self.0.cx.directories(), state.environment.umu()) + .await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); } - Environment::attach_or_start(&self.state()?.environment, root, self.0.cx.clone()).await + Environment::start(&state.environment, runner.as_ref(), &root, &self.0.cx).await } async fn with_environment(&self, work: F) -> Result diff --git a/src/core.rs b/src/core.rs index 7e056e7..63a641e 100644 --- a/src/core.rs +++ b/src/core.rs @@ -64,19 +64,6 @@ impl Bottles { Ok(()) } - /// Rebuilds the shared Virgo base using the latest catalog Soda release. - /// That exact release must already be downloaded. Existing runtimes, snapshots, - /// and completed addon caches remain intact; stopped preparations adopt the base. - #[cfg(feature = "fvs")] - pub fn rebuild_virgo_base(&self) -> crate::Operation<()> { - let cx = self.context.clone(); - let addons = self.addons.clone(); - crate::Operation::new(move |progress, cancellation| async move { - progress.send_replace(Some(crate::Progress::new(crate::Stage::CreatingPrefix))); - crate::environment::artifacts::rebuild_base(&addons, &cx, &cancellation).await - }) - } - pub fn bottles(&self) -> &BottleManager { &self.bottles } diff --git a/src/environment/artifacts/mod.rs b/src/environment/artifacts/mod.rs index dfcb0ab..e18affa 100644 --- a/src/environment/artifacts/mod.rs +++ b/src/environment/artifacts/mod.rs @@ -6,13 +6,13 @@ pub(crate) use software::prepare_addon; use super::prefix::{FVS_BLOCK_SIZE, VirgoError}; use crate::{ - Addon, Addons, CatalogEntry, Component, Context, Slot, + Addon, Addons, CatalogEntry, Component, Context, EnvironmentError, IndexEntry, Slot, error::{Error, Result}, runner::Runner, }; use fvs_rs::{Layer, Repository, UnmountMode}; use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -36,7 +36,7 @@ fn latest_soda(entries: &[CatalogEntry]) -> Result<&CatalogEntry current) @@ -46,21 +46,24 @@ fn latest_soda(entries: &[CatalogEntry]) -> Result<&CatalogEntry, addons: &Addons) -> Result<()> { - if !addons - .component(soda.id()) - .is_some_and(|entry| entry.slot() == Slot::Runner && entry.version() == soda.version()) - { - return Err(VirgoError::SodaNotDownloaded { - id: soda.id(), - version: soda.version().into(), - } - .into()); - } - Ok(()) +fn downloaded_soda( + id: Uuid, + version: &str, + addons: &Addons, +) -> Result>> { + addons + .component(id) + .filter(|entry| entry.slot() == Slot::Runner && entry.version() == version) + .ok_or_else(|| { + EnvironmentError::SodaNotDownloaded { + id, + version: version.into(), + } + .into() + }) } // Caller holds the shared build mutex, including publication of the manifest. @@ -72,48 +75,24 @@ async fn ensure_base( if crate::utils::exists(&manifest(cx)).await? { return Ok(next_config::load(manifest(cx)).await?); } - build_base(addons, cx, cancellation).await -} - -pub(crate) async fn rebuild_base( - addons: &Addons, - cx: &Context, - cancellation: &CancellationToken, -) -> Result<()> { - let _build = cancellation - .run_until_cancelled(cx.artifact_build().lock()) - .await - .ok_or(Error::Cancelled)?; - build_base(addons, cx, cancellation).await?; - Ok(()) -} - -async fn build_base( - addons: &Addons, - cx: &Context, - cancellation: &CancellationToken, -) -> Result { if cancellation.is_cancelled() { return Err(Error::Cancelled); } let entries = addons.component_entries(); let selected = latest_soda(&entries)?; - let downloaded = addons - .component(selected.id()) - .filter(|entry| entry.slot() == Slot::Runner && entry.version() == selected.version()) - .ok_or_else(|| VirgoError::SodaNotDownloaded { - id: selected.id(), - version: selected.version().into(), - })?; + let downloaded = downloaded_soda(selected.id(), selected.version(), addons)?; let soda = Addon::from(downloaded.as_ref()); let runner = soda.load_runner(cx.directories(), None).await?; - let generation = cx - .directories() - .data_dir() - .join("virgo/bases") - .join(Uuid::new_v4().to_string()); - let prefix = generation.join("prefix"); - async_fs::create_dir_all(&prefix).await?; + let root = cx.directories().data_dir().join("virgo/soda"); + let prefix = root.join("prefix"); + async_fs::create_dir_all(&root).await?; + // Never reuse an unpublished prefix: failed shutdown may have left Wine alive. + async_fs::create_dir(&prefix).await.map_err(|error| { + std::io::Error::new( + error.kind(), + format!("cannot create Soda prefix at {}: {error}", prefix.display()), + ) + })?; let initialized = runner.wineboot(&prefix, "--init").await; // Keep storage if Wine cannot be stopped safely. super::shutdown_wine(runner.as_ref(), &prefix).await?; @@ -142,7 +121,7 @@ async fn build_base( } .await; if result.is_err() { - remove_dir(generation).await; + remove_dir(root).await; } result } @@ -163,7 +142,7 @@ pub(crate) async fn base_layers( Ok(vec![base.layer, adapter]) } -/// Loads or creates the runner adapter within this immutable base generation. +/// Loads or creates the selected runner adapter over the pinned Soda base. /// /// Creation is staged over the shared base and published by renaming the /// committed upper directory into the adapter cache. @@ -174,7 +153,7 @@ async fn ensure_adapter( context: &Context, cancellation: &CancellationToken, ) -> Result { - let root = Path::new(&base.repository_path).with_file_name("adapters"); + let root = context.directories().data_dir().join("virgo/soda/adapters"); let destination = root.join(runner_key); if async_fs::metadata(destination.join(".fvs2")) .await @@ -274,7 +253,7 @@ mod tests { assert_eq!(latest_soda(&entries).unwrap().id(), entries[1].id()); assert!(matches!( latest_soda(&[release("Soda", "invalid")]), - Err(Error::Virgo(VirgoError::InvalidSodaVersion(_))) + Err(Error::Environment(EnvironmentError::InvalidSodaVersion(_))) )); let root = std::env::temp_dir().join(format!("soda-test-{}", Uuid::new_v4())); let cx = Context::for_test(crate::Directories::from_path(&root).unwrap(), None).unwrap(); @@ -287,7 +266,7 @@ mod tests { let addons = Addons::load(cx.clone(), None, None).await.unwrap(); let cancellation = CancellationToken::new(); assert!( - matches!(rebuild_base(&addons, &cx, &cancellation).await, Err(Error::Virgo(VirgoError::SodaNotDownloaded { id, .. })) if id == entries[1].id()) + matches!(ensure_base(&addons, &cx, &cancellation).await, Err(Error::Environment(EnvironmentError::SodaNotDownloaded { id, .. })) if id == entries[1].id()) ); let soda = serde_json::from_value( @@ -316,15 +295,6 @@ mod tests { let pinned = ensure_base(&addons, &cx, &cancellation).await.unwrap(); assert_eq!(pinned.soda.id(), entries[0].id()); assert_eq!(pinned.layer, layer); - assert!(rebuild_base(&addons, &cx, &cancellation).await.is_err()); - assert_eq!( - ensure_base(&addons, &cx, &cancellation) - .await - .unwrap() - .layer, - layer - ); - let id = Uuid::new_v4(); async_fs::create_dir_all( cx.directories() diff --git a/src/environment/artifacts/software.rs b/src/environment/artifacts/software.rs index 99ef57c..b1c489c 100644 --- a/src/environment/artifacts/software.rs +++ b/src/environment/artifacts/software.rs @@ -9,7 +9,7 @@ use super::{cache, downloaded_soda, ensure_base}; use crate::{ AddonError, Addons, Context, EnvVars, EnvironmentError, Progress, Requirement, Slot, Stage, addons::{Artifact, InstallInputs, execute, replay_env_vars}, - environment::{EnvironmentConfig, prefix::VirgoError}, + environment::EnvironmentConfig, error::{Error, Result}, }; @@ -61,7 +61,7 @@ fn visit( return Ok(()); } if visiting.contains(&id) { - return Err(VirgoError::CyclicPrerequisites(id).into()); + return Err(EnvironmentError::CyclicPrerequisites(id).into()); } visiting.push(id); for prerequisite in prerequisites(id, config)? { @@ -77,16 +77,7 @@ fn resources(id: Uuid, addons: &Addons, cx: &Context) -> Result> { return Ok(vec![component.artifact(cx.directories())]); } let dependency = addons.dependency(id).ok_or(AddonError::NotFound(id))?; - Ok(dependency - .artifacts() - .iter() - .map(|artifact| { - Artifact::new( - dependency.path(cx.directories()).join(&artifact.path), - artifact.steps.clone(), - ) - }) - .collect()) + Ok(dependency.resources(cx.directories())) } pub(crate) async fn prepare_addon( @@ -101,13 +92,13 @@ pub(crate) async fn prepare_addon( .run_until_cancelled(cx.artifact_build().lock()) .await .ok_or(Error::Cancelled)?; - // UUID alone is the cache identity, even across base rebuilds and settings changes. + // UUID alone is the cache identity, independent of owner settings and runner. if cache::exists(id, cx).await? { return Ok(()); } let base = ensure_base(addons, cx, cancellation).await?; - downloaded_soda(&base.soda, addons)?; - let runner = base.soda.load_runner(cx.directories(), None).await?; + let soda = downloaded_soda(base.soda.id(), base.soda.version(), addons)?; + let runner = soda.load_runner(cx.directories(), None).await?; let winebridge = addons .latest_component(Slot::WineBridge) .ok_or(EnvironmentError::ComponentNotInstalled(Slot::WineBridge))? @@ -199,7 +190,7 @@ mod tests { config.dependencies[2] = dependency(ids[2], vec![Requirement::Id(ids[0])]); assert!(matches!( visit(ids[0], &config, &mut Vec::new(), &mut Vec::new()), - Err(Error::Virgo(VirgoError::CyclicPrerequisites(_))) + Err(Error::Environment(EnvironmentError::CyclicPrerequisites(_))) )); } } diff --git a/src/environment/error.rs b/src/environment/error.rs index 41138d9..03392f5 100644 --- a/src/environment/error.rs +++ b/src/environment/error.rs @@ -5,6 +5,19 @@ use uuid::Uuid; /// Failures in shared execution configuration and operations. #[derive(Debug, Error)] pub enum EnvironmentError { + #[cfg(feature = "fvs")] + #[error("no Soda runner release in the current component catalog")] + SodaNotInCatalog, + #[cfg(feature = "fvs")] + #[error("invalid Soda semantic version: {0}")] + InvalidSodaVersion(String), + #[cfg(feature = "fvs")] + #[error("download Soda {version} ({id}) before building the Virgo base or an addon layer")] + SodaNotDownloaded { id: Uuid, version: String }, + #[cfg(feature = "fvs")] + #[error("cyclic addon prerequisites involving {0}")] + CyclicPrerequisites(Uuid), + /// Cleanup could not finish; the prefix remains available for explicit shutdown. #[error( "cleanup failed at {prefix}; stop Wine and unmount this prefix before retrying: {source}" diff --git a/src/environment/mod.rs b/src/environment/mod.rs index 45f6008..b67f262 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -10,7 +10,7 @@ mod software; pub(crate) use software::reconcile; -use std::path::{Path, PathBuf}; +use std::path::Path; use tokio_util::sync::CancellationToken; @@ -34,40 +34,37 @@ pub(crate) struct Environment { } impl Environment { - /// Refreshes only the shared base and runner adapter while the owner is stopped. - /// The owner saves changed materialization references before starting Wine. - #[cfg(feature = "fvs")] - pub(crate) async fn refresh_base( + /// Resolves layers at creation or when the stopped owner changes runners. + pub(crate) async fn prepare( config: &mut EnvironmentConfig, - root: &Path, + runner: &dyn Runner, addons: &crate::Addons, cx: &Context, cancellation: &CancellationToken, - ) -> Result { - let Storage::Virgo { layers } = &config.storage else { - return Ok(false); - }; - let runner = config - .runner() - .load_runner(cx.directories(), config.umu()) - .await?; - let base = artifacts::base_layers( - runner.as_ref(), - &config.runner().id().to_string(), - addons, - cx, - cancellation, - ) - .await?; - if layers.starts_with(&base) { - return Ok(false); + ) -> Result<()> { + let _ = (runner, addons, cx, cancellation); + match &mut config.storage { + Storage::Standard => Ok(()), + #[cfg(feature = "fvs")] + Storage::Virgo { layers } => { + let base = artifacts::base_layers( + runner, + &config.components[&crate::Slot::Runner].id().to_string(), + addons, + cx, + cancellation, + ) + .await?; + layers.splice(..layers.len().min(2), base); + Ok(()) + } } - Self::stop(config, root, cx).await?; - let Storage::Virgo { layers } = &mut config.storage else { - unreachable!() - }; - layers.splice(..layers.len().min(2), base); - Ok(true) + } + + pub(crate) async fn initialize(runner: &dyn Runner, prefix: &Path) -> Result<()> { + let initialized = runner.wineboot(prefix, "--init").await; + shutdown_wine(runner, prefix).await?; + initialized } /// Stops Wine and unmounts storage without requiring a live handle or bridge. @@ -80,34 +77,23 @@ impl Environment { prefix::stop(&config.storage, root, cx).await } - /// Connects to an existing runtime or prepares and starts one. - pub(crate) async fn attach_or_start( + /// Starts from references already resolved and published by the owner. + pub(crate) async fn start( config: &EnvironmentConfig, - root: PathBuf, - cx: Context, + runner: &dyn Runner, + root: &Path, + cx: &Context, ) -> Result { - if let Some(environment) = Self::try_attach(&root).await? { - return Ok(environment); - } - let runner = config - .runner() - .load_runner(cx.directories(), config.umu()) - .await?; - prefix::prepare(&config.storage, &root, &cx).await?; + prefix::prepare(&config.storage, root, cx).await?; let prefix = root.join("prefix"); let command = config.wrappers.apply( - WineBridgeClient::command( - runner.as_ref(), - &prefix, - config.winebridge().path(cx.directories()), - ) - .envs(config.env_vars.iter()), + WineBridgeClient::command(runner, &prefix, config.winebridge().path(cx.directories())) + .envs(config.env_vars.iter()), ); let bridge = match WineBridgeClient::connect_or_spawn(&prefix, command).await { Ok(bridge) => bridge, Err(error) => { - shutdown_wine(runner.as_ref(), &prefix).await?; - prefix::stop(&config.storage, &root, &cx).await?; + Self::stop(config, root, cx).await?; return Err(error); } }; diff --git a/src/environment/prefix/mod.rs b/src/environment/prefix/mod.rs index 7d3c1ac..8552c37 100644 --- a/src/environment/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -4,7 +4,6 @@ //! ordered FVS layer stack with a private writable upper directory. Virgo addon //! changes use rollback checkpoints; Standard uses FVS only for explicit snapshots. -mod standard; #[cfg(feature = "fvs")] mod virgo; @@ -23,7 +22,7 @@ use { fvs_rs::{Commit, Layer, Progress as FvsProgress, RestoreResponse, error::Error as FvsError}, }; -use crate::{Context, error::Result, runner::Runner}; +use crate::{Context, error::Result}; /// Identifies rollback checkpoints that must not appear as user snapshots. /// @@ -65,26 +64,13 @@ impl From<&FvsProgress> for Transfer { } /// Creates storage at an explicit owner location. -pub(crate) async fn create( - storage: &mut Storage, - root: &Path, - runner: &dyn Runner, - runner_key: &str, - context: &Context, - addons: &crate::Addons, - cancellation: &tokio_util::sync::CancellationToken, -) -> Result<()> { - #[cfg(not(feature = "fvs"))] - let _ = (runner_key, context, addons, cancellation); - match storage { - Storage::Standard => standard::create(&root.join("prefix"), runner).await, +pub(crate) async fn create(storage: &Storage, root: &Path) -> Result<()> { + let directory = match storage { + Storage::Standard => "prefix", #[cfg(feature = "fvs")] - Storage::Virgo { layers } => { - *layers = - virgo::create(root, runner, runner_key, context, addons, cancellation).await?; - Ok(()) - } - } + Storage::Virgo { .. } => "upper", + }; + Ok(async_fs::create_dir_all(root.join(directory)).await?) } pub(crate) async fn prepare(storage: &Storage, root: &Path, context: &Context) -> Result<()> { @@ -105,36 +91,6 @@ pub(crate) async fn stop(storage: &Storage, root: &Path, context: &Context) -> R } } -pub(crate) async fn rebuild( - storage: &mut Storage, - runner: &dyn Runner, - runner_key: &str, - installed: &[Uuid], - context: &Context, - addons: &crate::Addons, - cancellation: &tokio_util::sync::CancellationToken, -) -> Result<()> { - match storage { - Storage::Standard => { - let _ = (runner, runner_key, installed, context, addons, cancellation); - Ok(()) - } - #[cfg(feature = "fvs")] - Storage::Virgo { layers } => { - virgo::rebuild( - layers, - runner, - runner_key, - installed, - context, - addons, - cancellation, - ) - .await - } - } -} - /// Applies one addon to storage; the enclosing software workflow owns cleanup. pub(crate) async fn install( storage: &mut Storage, diff --git a/src/environment/prefix/standard.rs b/src/environment/prefix/standard.rs deleted file mode 100644 index 269b2b4..0000000 --- a/src/environment/prefix/standard.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Direct mutable prefix storage. -//! -//! Recipes operate on `/prefix`. Uninstallation asks the recipe to -//! restore overwritten files because, unlike Virgo, this backend has no lower -//! layer to reveal. The software workflow owns mutation rollback. - -use std::path::Path; - -use crate::{error::Result, runner::Runner}; - -pub(super) async fn create(prefix: &Path, runner: &dyn Runner) -> Result<()> { - let result = runner.wineboot(prefix, "--init").await; - crate::environment::shutdown_wine(runner, prefix).await?; - result -} diff --git a/src/environment/prefix/virgo/mod.rs b/src/environment/prefix/virgo/mod.rs index 26a352d..94eeca3 100644 --- a/src/environment/prefix/virgo/mod.rs +++ b/src/environment/prefix/virgo/mod.rs @@ -5,7 +5,7 @@ //! persisted by the owner and must be changed only while the owner is //! stopped. -use crate::environment::artifacts::{self, cache}; +use crate::environment::artifacts::cache; use std::{ ops::AsyncFnOnce, @@ -16,7 +16,7 @@ use futures_lite::StreamExt; use fvs_rs::{Layer, UnmountMode}; use uuid::Uuid; -use crate::{Context, error::Result, runner::Runner}; +use crate::{Context, error::Result}; /// Virgo-specific failures carried by [`crate::error::Error::Virgo`]. #[derive(Debug, thiserror::Error)] @@ -29,14 +29,6 @@ pub enum VirgoError { /// Requested full or abbreviated state ID. state: String, }, - #[error("no Soda runner release in the current component catalog")] - SodaNotInCatalog, - #[error("invalid Soda semantic version: {0}")] - InvalidSodaVersion(String), - #[error("download Soda {version} ({id}) before building the Virgo base or an addon layer")] - SodaNotDownloaded { id: Uuid, version: String }, - #[error("cyclic addon prerequisites involving {0}")] - CyclicPrerequisites(Uuid), /// Virgo cannot mount a prefix over a nonempty mountpoint. #[error("mountpoint is not empty: {0}")] DirtyMountpoint(PathBuf), @@ -52,19 +44,6 @@ pub enum VirgoError { Registry(String), } -pub(super) async fn create( - root: &Path, - runner: &dyn Runner, - runner_key: &str, - context: &Context, - addons: &crate::Addons, - cancellation: &tokio_util::sync::CancellationToken, -) -> Result> { - let upper = root.join("upper"); - async_fs::create_dir_all(upper).await?; - artifacts::base_layers(runner, runner_key, addons, context, cancellation).await -} - pub(super) async fn prepare(root: &Path, layers: &[Layer], context: &Context) -> Result<()> { if !existing_mount(root, layers, context).await? { let prefix = root.join("prefix"); @@ -116,26 +95,6 @@ pub(super) async fn stop(root: &Path, context: &Context) -> Result<()> { Ok(()) } -pub(super) async fn rebuild( - layers: &mut Vec, - runner: &dyn Runner, - runner_key: &str, - installed: &[Uuid], - context: &Context, - addons: &crate::Addons, - cancellation: &tokio_util::sync::CancellationToken, -) -> Result<()> { - // Build separately so failure to resolve any cached addon does not partially - // replace the owner's persisted layer order. - let mut rebuilt = - artifacts::base_layers(runner, runner_key, addons, context, cancellation).await?; - for id in installed { - rebuilt.push(cache::layer(*id, context).await?); - } - *layers = rebuilt; - Ok(()) -} - pub(super) async fn install( root: &Path, layers: &mut Vec, diff --git a/src/environment/software.rs b/src/environment/software.rs index 1b0f611..75e547d 100644 --- a/src/environment/software.rs +++ b/src/environment/software.rs @@ -6,12 +6,13 @@ use strum::IntoEnumIterator; use tokio::sync::watch; use tokio_util::sync::CancellationToken; -use super::{EnvironmentConfig, EnvironmentError, Storage, prefix}; +#[cfg(feature = "fvs")] +use super::Storage; +use super::{EnvironmentConfig, EnvironmentError, prefix}; use crate::{ Addon, AddonError, Addons, Context, Progress, Slot, Stage, - addons::{Artifact, InstallInputs, execute, replay_env_vars, uninstall}, + addons::{InstallInputs, execute, replay_env_vars, uninstall}, error::{Error, Result}, - runner::Runner, }; /// Called while the owner is coordinated and stopped. The owner publishes @@ -91,16 +92,7 @@ pub(crate) async fn reconcile( ) .into()); } - let resources = downloaded - .artifacts() - .iter() - .map(|artifact| { - Artifact::new( - downloaded.path(cx.directories()).join(&artifact.path), - artifact.steps.clone(), - ) - }) - .collect(); + let resources = downloaded.resources(cx.directories()); installations.push((new.id(), None, resources)); } @@ -120,24 +112,21 @@ pub(crate) async fn reconcile( .await?; } } - #[cfg(feature = "fvs")] - if !runner_changed { - super::Environment::refresh_base(candidate, root, addons, cx, cancellation).await?; + if runner_changed { + super::Environment::prepare(candidate, runner.as_ref(), addons, cx, cancellation).await?; } let winebridge = candidate.winebridge().path(cx.directories()); - let env_vars = &mut candidate.env_vars; for (id, resources) in &removals { transact( - &mut candidate.storage, + candidate, root, - runner.as_ref(), cx, cancellation, progress, - async |storage| { + async |config| { prefix::uninstall( - storage, + &mut config.storage, root, *id, async |prefix, restore_files| { @@ -146,7 +135,7 @@ pub(crate) async fn reconcile( prefix, runner: runner.as_ref(), winebridge: &winebridge, - env_vars, + env_vars: &mut config.env_vars, }, resources, restore_files, @@ -165,40 +154,16 @@ pub(crate) async fn reconcile( ) .await?; } - if runner_changed { - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - progress.send_replace(Some(Progress::new(Stage::Rebuilding))); - let installed = Slot::iter() - .filter(|slot| !slot.is_runtime()) - .filter_map(|slot| previous.component(slot)) - .map(Addon::id) - .filter(|id| !removals.iter().any(|(removed, _)| removed == id)) - .chain(previous.dependencies.iter().map(Addon::id)) - .collect::>(); - prefix::rebuild( - &mut candidate.storage, - runner.as_ref(), - &candidate.components[&Slot::Runner].id().to_string(), - &installed, - cx, - addons, - cancellation, - ) - .await?; - } for (id, replaced, resources) in installations { transact( - &mut candidate.storage, + candidate, root, - runner.as_ref(), cx, cancellation, progress, - async |storage| { + async |config| { prefix::install( - storage, + &mut config.storage, root, id, replaced, @@ -208,7 +173,7 @@ pub(crate) async fn reconcile( prefix, runner: runner.as_ref(), winebridge: &winebridge, - env_vars, + env_vars: &mut config.env_vars, }, &resources, cancellation, @@ -224,7 +189,7 @@ pub(crate) async fn reconcile( }, ) .await?; - replay_env_vars(env_vars, &resources); + replay_env_vars(&mut candidate.env_vars, &resources); } Ok(()) } @@ -233,13 +198,12 @@ pub(crate) async fn reconcile( /// Standard keeps direct writes. Owner configuration is saved separately. /// Failed shutdown/unmount returns before any rollback can touch live storage. async fn transact( - storage: &mut Storage, + config: &mut EnvironmentConfig, root: &Path, - runner: &dyn Runner, cx: &Context, cancellation: &CancellationToken, progress: &watch::Sender>, - work: impl for<'a> std::ops::AsyncFnOnce(&'a mut Storage) -> Result<()>, + work: impl for<'a> std::ops::AsyncFnOnce(&'a mut EnvironmentConfig) -> Result<()>, ) -> Result<()> { #[cfg(feature = "fvs")] let repository = fvs_rs::Repository { @@ -247,7 +211,7 @@ async fn transact( block_size: prefix::FVS_BLOCK_SIZE, }; #[cfg(feature = "fvs")] - let checkpoint = if matches!(storage, Storage::Virgo { .. }) { + let checkpoint = if matches!(config.storage, Storage::Virgo { .. }) { let stream = cx .fvs() .await? @@ -269,9 +233,8 @@ async fn transact( if cancellation.is_cancelled() { return Err(Error::Cancelled); } - let result = work(storage).await; - super::shutdown_wine(runner, &root.join("prefix")).await?; - prefix::stop(storage, root, cx).await?; + let result = work(config).await; + super::Environment::stop(config, root, cx).await?; let result = if result.is_ok() && cancellation.is_cancelled() { Err(Error::Cancelled) } else { From 45c6dc4adac7706a9d6a5ce140f9d70c6dba4295 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Fri, 11 Sep 2026 12:36:32 +0530 Subject: [PATCH 10/24] refactor(core): derive Virgo composition from environment configuration --- README.md | 40 ++-- src/addons/index/mod.rs | 1 + src/addons/installer/engine.rs | 49 +++-- src/addons/installer/mod.rs | 2 + src/bottle/edit.rs | 82 +++++--- src/bottle/manager.rs | 5 + src/bottle/mod.rs | 2 +- src/bottle/snapshot.rs | 94 ++++----- src/bottle/software.rs | 2 +- src/bottle/state.rs | 2 +- src/environment/artifacts/cache.rs | 56 +---- src/environment/artifacts/mod.rs | 1 + src/environment/artifacts/software.rs | 2 + src/environment/config.rs | 38 ++++ src/environment/error.rs | 7 + src/environment/history.rs | 207 +++++++++++++++++++ src/environment/mod.rs | 27 ++- src/environment/prefix/mod.rs | 182 +--------------- src/environment/prefix/virgo/mod.rs | 43 +--- src/environment/registry.rs | 127 ++++++++++++ src/environment/software.rs | 286 +++++++++----------------- src/runner/mod.rs | 9 - src/runner/proton.rs | 23 ++- src/runner/wine.rs | 9 +- src/winebridge.rs | 13 +- 25 files changed, 703 insertions(+), 606 deletions(-) create mode 100644 src/environment/history.rs create mode 100644 src/environment/registry.rs diff --git a/README.md b/README.md index fcda2d0..1e5c84e 100644 --- a/README.md +++ b/README.md @@ -58,17 +58,19 @@ The callback receives a draft of the latest state under the owner lock. Edit `name`, `programs`, and `environment` directly; errors discard the whole draft. Metadata edits work while running. Call `stop()` before changing environment settings, including through `set_component`, `remove_component`, or `install`. -Storage is fixed at creation; existing dependencies remain in installation order. -New dependency selections can be appended. Prefix changes run before publication; -batch rollback of those effects remains part of the later composition work. -Settings-only edits save the draft without preparing or mutating the prefix. +Storage is fixed at creation. Standard dependencies may be appended; Virgo +selections may also be removed or reordered. Selections are validated before prefix +work. Virgo prefix mutations checkpoint the complete owner and save configuration +before publication; failures restore both configuration and persistent data. +Settings-only edits save configuration atomically. Standard keeps direct writes. +Metadata-only edits remain available while running. Virgo builds a clean shared base from the latest catalog runner named `Soda` (case-insensitive, semantic-version ordering). That exact release must already be downloaded. The base manifest pins its release and immutable FVS revision across catalog refreshes. The base is initialized once and reused; there is no rebuild -operation. Startup mounts the owner's saved layers directly; creation and runner -changes resolve the base and adapter references. New bases and adapters use +operation. Startup mounts the owner's saved layers directly; creation and addon +selection changes resolve the exact layer revisions. New bases and adapters use `virgo/soda`; existing manifest references and addon caches remain usable. Addon cache misses use pinned Soda and declared prerequisite layers, without @@ -76,13 +78,25 @@ owner settings, wrappers, or private writable data. UUID remains the sole cache identity: completed caches survive runner and settings changes. Runner adapters are built using the selected runner over the pinned base. Shared construction is serialized within one core instance. Standard installers continue -using their owner's runner. Managed registry-baseline composition remains step 8; -Virgo retains its existing per-addon checkpoint boundary. - -Bottle configuration requires execution settings under `environment`, with -resolved FVS layers retained inside `environment.storage` for Virgo. Old -configurations are rejected during deserialization and left untouched; recreate -those bottles to use the new format. Completed addon caches remain reusable. +using their owner's runner. + +Virgo composition is always Soda base → selected runner adapter → components in +slot order → dependencies in persisted order → private writable upper. Reordering +or removing layers leaves private files, updates, saves, and whiteouts intact. +Cached registry patches are applied in that same order. The owner retains a managed +registry baseline and reapplies private registry changes after rebuilding it. + +Recipe-generated environment variables are persisted separately from explicit +settings. Explicit settings take precedence; execution-owned variables such as +`WINEPREFIX`, `WINEARCH`, and `PROTONPATH` are applied last. Snapshots capture owner +metadata, exact layer references, registry baseline, and private prefix data while +stopped, without WineBridge discovery files. + +Bottle configuration uses version 2, with execution settings under `environment` +and resolved FVS layers inside `environment.storage` for Virgo. Version 1 cannot +separate already-mixed explicit and generated settings or recover the old registry +baseline. It is rejected and left untouched; recreate those bottles to use this +format. Completed addon caches remain reusable. Operations are lazy. Await them, call `cancel().await`, or spawn them and explicitly detach the task; dropping an operation abandons it. diff --git a/src/addons/index/mod.rs b/src/addons/index/mod.rs index 669a943..8d073c8 100644 --- a/src/addons/index/mod.rs +++ b/src/addons/index/mod.rs @@ -166,6 +166,7 @@ impl IndexEntry { self.addon.path(directories) } + #[cfg(feature = "fvs")] pub(crate) fn artifact(&self, directories: &Directories) -> Artifact { self.addon.artifact(directories) } diff --git a/src/addons/installer/engine.rs b/src/addons/installer/engine.rs index dd4ba9f..85e0b74 100644 --- a/src/addons/installer/engine.rs +++ b/src/addons/installer/engine.rs @@ -35,6 +35,7 @@ pub(crate) async fn execute( runner, winebridge, env_vars, + explicit_env_vars, } = inputs; check_cancellation(cancellation)?; for resource in resources { @@ -46,6 +47,7 @@ pub(crate) async fn execute( runner, winebridge, env_vars: &mut *env_vars, + explicit_env_vars, }, resource, step, @@ -60,14 +62,13 @@ pub(crate) async fn execute( /// Attempts to undo a recipe in reverse resource and step order. /// -/// File copies are restored or removed only when `restore_files` is true. Environment entries are +/// File copies are restored or removed. Environment entries are /// removed and DLL overrides are deleted. Other step kinds have no inverse and are skipped with a /// warning. File, bridge and override failures are logged and ignored; cancellation is returned. /// The enclosing prefix scope owns Wine shutdown. pub(crate) async fn uninstall( inputs: InstallInputs<'_>, resources: &[Artifact], - restore_files: bool, item_id: Uuid, cancellation: &CancellationToken, on_step: impl Fn(&InstallStep) + Send, @@ -77,6 +78,7 @@ pub(crate) async fn uninstall( runner, winebridge, env_vars, + explicit_env_vars, } = inputs; check_cancellation(cancellation)?; @@ -89,9 +91,9 @@ pub(crate) async fn uninstall( runner, winebridge, env_vars: &mut *env_vars, + explicit_env_vars, }, step, - restore_files, item_id, cancellation, ) @@ -121,8 +123,14 @@ async fn maintenance_bridge( prefix: &Path, executable: &Path, env_vars: &EnvVars, + explicit_env_vars: &EnvVars, ) -> Result { - let command = WineBridgeClient::command(runner, prefix, executable).envs(env_vars.iter()); + let command = WineBridgeClient::command( + runner, + prefix, + executable, + env_vars.iter().chain(explicit_env_vars.iter()), + ); WineBridgeClient::connect_or_spawn(prefix, command).await } @@ -137,6 +145,7 @@ async fn execute_step( runner, winebridge, env_vars, + explicit_env_vars, } = inputs; match step { InstallStep::Copy { @@ -158,7 +167,7 @@ async fn execute_step( for argument in arguments { command = command.arg(argument); } - for (name, value) in env_vars.iter() { + for (name, value) in env_vars.iter().chain(explicit_env_vars.iter()) { command = command.env(name, value); } let status = @@ -171,7 +180,7 @@ async fn execute_step( for dll in dlls { check_cancellation(cancellation)?; let mut command = Command::new("regsvr32").arg("/s").arg(prefix.join(dll)); - for (name, value) in env_vars.iter() { + for (name, value) in env_vars.iter().chain(explicit_env_vars.iter()) { command = command.env(name, value); } let status = @@ -187,14 +196,16 @@ async fn execute_step( name, value, } => { - let bridge = maintenance_bridge(runner, prefix, winebridge, env_vars).await?; + let bridge = + maintenance_bridge(runner, prefix, winebridge, env_vars, explicit_env_vars).await?; check_cancellation(cancellation)?; bridge .set_registry_value(*hive, key.clone(), name.clone(), value.clone()) .await?; } InstallStep::SetDllOverrides { dlls, mode } => { - let bridge = maintenance_bridge(runner, prefix, winebridge, env_vars).await?; + let bridge = + maintenance_bridge(runner, prefix, winebridge, env_vars, explicit_env_vars).await?; for dll in dlls { check_cancellation(cancellation)?; bridge.set_dll_override(dll.clone(), *mode).await?; @@ -211,7 +222,6 @@ async fn execute_step( async fn uninstall_step( inputs: InstallInputs<'_>, step: &InstallStep, - restore_files: bool, addon_id: Uuid, cancellation: &CancellationToken, ) -> Result<()> { @@ -220,26 +230,29 @@ async fn uninstall_step( runner, winebridge, env_vars, + explicit_env_vars, } = inputs; match step { - InstallStep::Copy { destination, .. } if restore_files => { + InstallStep::Copy { destination, .. } => { if let Err(error) = uninstall_file(prefix, destination).await { tracing::warn!(%error); } } - InstallStep::Copy { .. } => {} InstallStep::SetEnvironment { name, .. } => { env_vars.remove(name); WineBridgeClient::shutdown_existing(prefix).await.log_warn(); } InstallStep::SetDllOverrides { dlls, .. } => { - let bridge = match maintenance_bridge(runner, prefix, winebridge, env_vars).await { - Ok(bridge) => bridge, - Err(error) => { - tracing::warn!(%error); - return Ok(()); - } - }; + let bridge = + match maintenance_bridge(runner, prefix, winebridge, env_vars, explicit_env_vars) + .await + { + Ok(bridge) => bridge, + Err(error) => { + tracing::warn!(%error); + return Ok(()); + } + }; for dll in dlls.iter().rev() { check_cancellation(cancellation)?; match bridge.delete_dll_override(dll.clone()).await { diff --git a/src/addons/installer/mod.rs b/src/addons/installer/mod.rs index 382309e..92ebc2f 100644 --- a/src/addons/installer/mod.rs +++ b/src/addons/installer/mod.rs @@ -155,6 +155,8 @@ pub(crate) struct InstallInputs<'a> { pub(crate) winebridge: &'a Path, /// The environment updated by `SetEnvironment` steps and passed to processes. pub(crate) env_vars: &'a mut EnvVars, + /// Explicit owner settings override recipe contributions for every process. + pub(crate) explicit_env_vars: &'a EnvVars, } impl Addon { diff --git a/src/bottle/edit.rs b/src/bottle/edit.rs index 162dc4d..78e31af 100644 --- a/src/bottle/edit.rs +++ b/src/bottle/edit.rs @@ -14,11 +14,11 @@ impl Bottle { /// together; cloned handles serialize edits against the latest state. /// /// Metadata can change while running. Environment changes require an explicit - /// stop first. Storage and existing dependency order cannot be changed; new - /// dependencies may be appended. Addon selections must be downloaded. + /// stop first. Storage is fixed; Standard dependencies may only be appended. + /// Addon selections must be downloaded. /// Standard mutations write directly; failed recipes can leave partial effects. - /// Prefix effects are not yet rolled back as a batch if reconciliation or - /// persistence fails; no candidate configuration is published on failure. + /// Virgo prefix mutations checkpoint data and configuration together and restore both + /// on failure. Settings-only edits use atomic configuration saving. pub fn edit( &self, callback: impl FnOnce(&mut BottleState) -> Result<()> + Send + 'static, @@ -54,33 +54,69 @@ impl Bottle { program.validate()?; } draft.environment.validate_requirements()?; + let prefix_changed = crate::environment::validate_edit( + &previous.environment, + &draft.environment, + &bottle.0.addons, + )?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } - if draft.environment != previous.environment { - if crate::environment::Environment::try_attach( - &cx.directories().bottle(previous.id), - ) - .await? - .is_some() + let root = cx.directories().bottle(draft.id); + let environment_changed = draft.environment != previous.environment; + if environment_changed { + if crate::environment::Environment::try_attach(&root) + .await? + .is_some() { return Err(crate::EnvironmentError::MustBeStopped.into()); } - crate::environment::reconcile( - &previous.environment, - &mut draft.environment, - &cx.directories().bottle(draft.id), - cx, - &bottle.0.addons, - &progress, - &cancellation, - ) - .await?; + crate::environment::Environment::stop(&previous.environment, &root, cx).await?; } - if cancellation.is_cancelled() { - return Err(Error::Cancelled); + #[cfg(feature = "fvs")] + let checkpoint = if prefix_changed + && matches!(previous.environment.storage, crate::Storage::Virgo { .. }) + { + Some( + crate::environment::history::capture( + &root, + crate::environment::history::AUTO_CHECKPOINT_MESSAGE.into(), + Stage::Checkpointing, + cx, + &progress, + ) + .await?, + ) + } else { + None + }; + let result = async { + if prefix_changed { + crate::environment::reconcile( + &previous.environment, + &mut draft.environment, + &cx.directories().bottle(draft.id), + cx, + &bottle.0.addons, + &progress, + &cancellation, + ) + .await?; + } + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + Self::save_state(&draft, cx).await } - Self::save_state(&draft, cx).await?; + .await; + #[cfg(feature = "fvs")] + let result = if let Some(checkpoint) = checkpoint { + crate::environment::history::recover(result, &root, &checkpoint, cx, &progress) + .await + } else { + result + }; + result?; bottle.publish(draft); Ok(()) }) diff --git a/src/bottle/manager.rs b/src/bottle/manager.rs index 59e9d72..1d416be 100644 --- a/src/bottle/manager.rs +++ b/src/bottle/manager.rs @@ -223,6 +223,7 @@ impl BottleManager { components, dependencies: Vec::new(), env_vars: Default::default(), + addon_env_vars: Default::default(), wrappers: Default::default(), }; #[cfg(feature = "fvs")] @@ -244,6 +245,10 @@ impl BottleManager { .await?; } let result = async { + #[cfg(feature = "fvs")] + if let Storage::Virgo { layers } = &config.storage { + crate::environment::registry::compose(&bottle_path, layers, &[], &cx).await?; + } if cancellation.is_cancelled() { return Err(Error::Cancelled); } diff --git a/src/bottle/mod.rs b/src/bottle/mod.rs index ead1dc9..4a9338e 100644 --- a/src/bottle/mod.rs +++ b/src/bottle/mod.rs @@ -14,7 +14,7 @@ //! persisted state until a bottle operation explicitly replaces them. //! //! With the default `fvs` feature, bottles support caller-visible snapshots. -//! Standard history is created on demand; Virgo also checkpoints addon changes. +//! Standard history is created on demand; Virgo checkpoints complete configuration and prefix edits. //! Long-running mutations return lazy //! [`crate::Operation`] values and serialize with edits, stopping, snapshots, //! and deletion. WineBridge-backed control calls share that coordination. diff --git a/src/bottle/snapshot.rs b/src/bottle/snapshot.rs index d5d0a0c..3f90f64 100644 --- a/src/bottle/snapshot.rs +++ b/src/bottle/snapshot.rs @@ -1,12 +1,8 @@ //! Snapshot history operations. -use std::path::Path; - -use fvs_rs::{Repository, RestoreResponse}; - use crate::{ - Operation, Progress, Stage, Transfer, - environment::prefix::{AUTO_CHECKPOINT_MESSAGE, FVS_BLOCK_SIZE, finish_commit, finish_restore}, + Operation, Progress, Stage, + environment::history::{self, AUTO_CHECKPOINT_MESSAGE}, error::{Error, Result}, }; @@ -36,7 +32,6 @@ impl Bottle { /// the snapshot cannot be created. pub fn create_snapshot(&self, message: impl Into) -> Operation { let bottle = self.clone(); - let repository = self.snapshot_repository(); let cx = self.0.cx.clone(); let message = message.into(); Operation::new(move |progress, cancellation| async move { @@ -53,19 +48,13 @@ impl Bottle { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - let client = cx.fvs().await?; - if !crate::utils::exists(&bottle.bottle_path().join(".fvs2")).await? { - client - .new_repository(bottle.bottle_path(), FVS_BLOCK_SIZE) - .await?; - } - let stream = client.commit_stream(&repository, message).await?; - finish_commit(stream, |update| { - progress.send_replace(Some(Progress::transferring( - Stage::Committing, - Transfer::from(update), - ))); - }) + history::capture( + &bottle.bottle_path(), + message, + Stage::Committing, + &cx, + &progress, + ) .await }) } @@ -89,13 +78,12 @@ impl Bottle { if !crate::utils::exists(&self.bottle_path().join(".fvs2")).await? { return Ok(Vec::new()); } - let repository = self.snapshot_repository(); Ok(self .0 .cx .fvs() .await? - .list_commits(&repository) + .list_commits(&history::repository(&self.bottle_path())) .await? .into_iter() .filter(|snapshot| snapshot.message != AUTO_CHECKPOINT_MESSAGE) @@ -106,8 +94,8 @@ impl Bottle { /// /// The operation takes exclusive bottle access. It stops the bottle, then /// replaces the complete bottle tree with the target; - /// files absent from that snapshot are removed. The state being replaced is - /// not saved automatically. On success, the returned string is the resolved + /// files absent from that snapshot are removed. A checkpoint protects the + /// current state if restore or metadata validation fails. The returned string is the resolved /// full state ID and the restored `bottle.toml` is published as a new /// [`BottleState`] snapshot. /// @@ -115,10 +103,8 @@ impl Bottle { /// commit to the target. Cancellation is observed before restore begins, /// but not while the FVS stream is running. /// - /// Restore changes the filesystem before loading and validating the - /// restored metadata. If that final step fails, the operation returns an - /// error after disk contents have changed, while the previously published - /// live state remains in place. + /// A failed restore or invalid metadata restores the previous files and + /// configuration before returning. Failed recovery reports the owner path. /// /// # Errors /// @@ -127,7 +113,6 @@ impl Bottle { /// requested, or the restored metadata has a different bottle UUID. pub fn rollback(&self, state_id_or_prefix: &str) -> Operation { let bottle = self.clone(); - let repository = self.snapshot_repository(); let bottle_path = self.bottle_path(); let cx = self.0.cx.clone(); let state_id_or_prefix = state_id_or_prefix.to_owned(); @@ -146,38 +131,33 @@ impl Bottle { return Err(Error::Cancelled); } - bottle.ensure_exists()?; - let stream = cx - .fvs() - .await? - .restore_stream(&repository, &state_id_or_prefix, None::<&Path>, true, false) - .await?; - let response: RestoreResponse = finish_restore(stream, |update| { - progress.send_replace(Some(Progress::transferring( - Stage::Restoring, - Transfer::from(update), - ))); - }) + let checkpoint = history::capture( + &bottle_path, + AUTO_CHECKPOINT_MESSAGE.into(), + Stage::Checkpointing, + &cx, + &progress, + ) .await?; - let path = bottle_path.join("bottle.toml"); - let state: BottleState = next_config::load(path).await?; - if state.id != bottle.0.id { - return Err(BottleError::IdMismatch { - expected: bottle.0.id, - actual: state.id, + let result = async { + let response = + history::restore(&bottle_path, &state_id_or_prefix, &cx, &progress).await?; + let state: BottleState = next_config::load(bottle_path.join("bottle.toml")).await?; + if state.id != bottle.0.id { + return Err(BottleError::IdMismatch { + expected: bottle.0.id, + actual: state.id, + } + .into()); } - .into()); + state.environment.validate_requirements()?; + Ok((response.state_id, state)) } + .await; + let (revision, state) = + history::recover(result, &bottle_path, &checkpoint, &cx, &progress).await?; bottle.publish(state); - Ok(response.state_id) + Ok(revision) }) } - - /// Addresses owner history, created on demand for Standard snapshots. - fn snapshot_repository(&self) -> Repository { - Repository { - repository_path: self.bottle_path().display().to_string(), - block_size: FVS_BLOCK_SIZE, - } - } } diff --git a/src/bottle/software.rs b/src/bottle/software.rs index 29b804c..be87816 100644 --- a/src/bottle/software.rs +++ b/src/bottle/software.rs @@ -167,7 +167,7 @@ impl Bottle { }) } - /// Permanently installs a downloaded dependency in a stopped environment. + /// Installs a downloaded dependency in a stopped environment. /// Reinstalling its UUID is a no-op. pub fn install(&self, id: Uuid) -> Operation<()> { let addons = self.0.addons.clone(); diff --git a/src/bottle/state.rs b/src/bottle/state.rs index ba52f17..d63a895 100644 --- a/src/bottle/state.rs +++ b/src/bottle/state.rs @@ -27,7 +27,7 @@ use crate::{Context, EnvironmentConfig, addons::Addons, error::Result}; /// snapshot was published. Obtain another snapshot to observe later changes. /// Component locations are derived from their slot and version. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Config)] -#[config(version = 1)] +#[config(version = 2)] pub struct BottleState { pub(crate) id: Uuid, pub name: String, diff --git a/src/environment/artifacts/cache.rs b/src/environment/artifacts/cache.rs index ad83c98..ff65685 100644 --- a/src/environment/artifacts/cache.rs +++ b/src/environment/artifacts/cache.rs @@ -5,13 +5,13 @@ //! can be merged into each owner's writable upper directory. use std::{ - fs, ops::AsyncFnOnce, path::{Path, PathBuf}, }; +use crate::environment::registry::{registry_files, write_forward}; use fvs_rs::{Layer, UnmountMode}; -use regdiff_rs::prelude::{Diff, Hive, Registry, apply_files}; +use regdiff_rs::prelude::apply_files; use uuid::Uuid; use crate::{ @@ -22,12 +22,6 @@ use crate::{ use crate::environment::prefix::{FVS_BLOCK_SIZE, VirgoError}; -/// Removes references without deleting a shared cache. -pub(crate) fn remove(layers: &mut Vec, id: Uuid, context: &Context) { - let repository = layer_path(id, context).display().to_string(); - layers.retain(|layer| layer.repository_path != repository); -} - /// Checks only for FVS repository metadata; [`layer`] validates its commit. pub(crate) async fn exists(id: Uuid, context: &Context) -> Result { let path = layer_path(id, context).join(".fvs2"); @@ -148,12 +142,9 @@ where result } -/// Merges a cached addon's registry patches into an owner's writable upper. +/// Merges a cached addon's registry patches into prepared registry hives. /// /// A missing patch directory means the addon has no recorded registry effects. -/// Both replacement hives are prepared in a scratch directory before either is -/// installed, but the final renames are not atomic as a pair. Scratch cleanup is -/// best-effort. pub(crate) async fn apply_registry(prefix: &Path, id: Uuid, context: &Context) -> Result<()> { let patches = registry_path(id, context); if !async_fs::metadata(&patches) @@ -164,26 +155,13 @@ pub(crate) async fn apply_registry(prefix: &Path, id: Uuid, context: &Context) - } let apply_prefix = prefix.to_path_buf(); - let stage = prefix.join(format!(".bottles-next-registry-{}", Uuid::new_v4())); blocking::unblock(move || { - fs::create_dir_all(&stage)?; - let result = (|| { - for (file, hive) in registry_files() { - apply_files( - apply_prefix.join(file), - patches.join(file), - stage.join(file), - hive, - ) + for (file, hive) in registry_files() { + let path = apply_prefix.join(file); + apply_files(&path, &patches.join(file), &path, hive) .map_err(|error| VirgoError::Registry(error.to_string()))?; - } - for (file, _) in registry_files() { - fs::rename(stage.join(file), apply_prefix.join(file))?; - } - Ok::<_, Error>(()) - })(); - let _ = fs::remove_dir_all(stage); - result + } + Ok(()) }) .await } @@ -229,24 +207,6 @@ fn registry_path(id: Uuid, context: &Context) -> PathBuf { registry_root(context).join(id.to_string()) } -fn registry_files() -> [(&'static str, Hive); 2] { - [ - ("user.reg", Hive::CurrentUser), - ("system.reg", Hive::LocalMachine), - ] -} - -fn write_forward(old: &Path, new: &Path, output: &Path, hive: Hive) -> Result<()> { - let old = - Registry::try_from(old, hive).map_err(|error| VirgoError::Registry(error.to_string()))?; - let new = - Registry::try_from(new, hive).map_err(|error| VirgoError::Registry(error.to_string()))?; - Registry::diff(&old, &new) - .serialize_file(output) - .map_err(|error| VirgoError::Registry(error.to_string()))?; - Ok(()) -} - async fn remove_file(path: &Path) -> std::io::Result<()> { match async_fs::remove_file(path).await { Ok(()) => Ok(()), diff --git a/src/environment/artifacts/mod.rs b/src/environment/artifacts/mod.rs index e18affa..de2079e 100644 --- a/src/environment/artifacts/mod.rs +++ b/src/environment/artifacts/mod.rs @@ -310,6 +310,7 @@ mod tests { components: Default::default(), dependencies: vec![], env_vars: Default::default(), + addon_env_vars: Default::default(), wrappers: Default::default(), }; let (progress, _) = tokio::sync::watch::channel(None); diff --git a/src/environment/artifacts/software.rs b/src/environment/artifacts/software.rs index b1c489c..bd555a7 100644 --- a/src/environment/artifacts/software.rs +++ b/src/environment/artifacts/software.rs @@ -134,6 +134,7 @@ pub(crate) async fn prepare_addon( runner: runner.as_ref(), winebridge: &winebridge, env_vars: &mut env_vars, + explicit_env_vars: &EnvVars::default(), }, &resources, cancellation, @@ -182,6 +183,7 @@ mod tests { dependency(ids[3], vec![]), ], env_vars: Default::default(), + addon_env_vars: Default::default(), wrappers: Default::default(), }; let mut order = Vec::new(); diff --git a/src/environment/config.rs b/src/environment/config.rs index 625d971..8690bde 100644 --- a/src/environment/config.rs +++ b/src/environment/config.rs @@ -4,6 +4,7 @@ use super::{EnvironmentError, Storage}; use crate::{Addon, Component, Dependency, EnvVars, Requirement, Slot, Wrappers, error::Result}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use strum::IntoEnumIterator; use uuid::Uuid; /// Execution settings embedded in a bottle or standalone program's saved state. @@ -17,11 +18,48 @@ pub struct EnvironmentConfig { pub dependencies: Vec>, #[serde(default, skip_serializing_if = "EnvVars::is_empty")] pub env_vars: EnvVars, + /// Derived recipe contributions, kept separate from explicit user settings. + #[serde(default, skip_serializing_if = "EnvVars::is_empty")] + pub(crate) addon_env_vars: EnvVars, #[serde(default)] pub wrappers: Wrappers, } impl EnvironmentConfig { + #[cfg(feature = "fvs")] + pub(crate) fn ordered_addons(&self) -> impl Iterator + '_ { + Slot::iter() + .filter(|slot| !slot.is_runtime()) + .filter_map(|slot| self.component(slot)) + .map(Addon::id) + .chain(self.dependencies.iter().map(Addon::id)) + } + + pub(crate) fn effective_env_vars(&self) -> impl Iterator { + self.addon_env_vars.iter().chain(self.env_vars.iter()) + } + + pub(crate) fn resolve_env_vars( + &mut self, + addons: &crate::Addons, + cx: &crate::Context, + ) -> Result<()> { + let mut vars = EnvVars::default(); + for slot in Slot::iter().filter(|slot| !slot.is_runtime()) { + if let Some(addon) = self.component(slot) { + crate::addons::replay_env_vars(&mut vars, &[addon.artifact(cx.directories())]); + } + } + for addon in &self.dependencies { + let entry = addons + .dependency(addon.id()) + .ok_or(crate::AddonError::NotFound(addon.id()))?; + crate::addons::replay_env_vars(&mut vars, &entry.resources(cx.directories())); + } + self.addon_env_vars = vars; + Ok(()) + } + /// Returns the runner recorded when this snapshot was published. /// /// Catalog refreshes do not replace this value. diff --git a/src/environment/error.rs b/src/environment/error.rs index 03392f5..7b17b5b 100644 --- a/src/environment/error.rs +++ b/src/environment/error.rs @@ -5,6 +5,13 @@ use uuid::Uuid; /// Failures in shared execution configuration and operations. #[derive(Debug, Error)] pub enum EnvironmentError { + #[cfg(feature = "fvs")] + #[error("rollback failed for {root}; repair or restore this owner before retrying: {source}")] + Rollback { + root: std::path::PathBuf, + #[source] + source: Box, + }, #[cfg(feature = "fvs")] #[error("no Soda runner release in the current component catalog")] SodaNotInCatalog, diff --git a/src/environment/history.rs b/src/environment/history.rs new file mode 100644 index 0000000..7196326 --- /dev/null +++ b/src/environment/history.rs @@ -0,0 +1,207 @@ +//! Owner history includes saved configuration, resolved layers, and persistent data. +//! Callers hold owner coordination and stop Wine and mounts before using it. + +use super::prefix::FVS_BLOCK_SIZE; +use crate::{Context, Progress, Stage, Transfer, error::Result}; +use futures_core::Stream; +use futures_util::TryStreamExt; +use fvs_rs::{ + Commit, Progress as FvsProgress, Repository, RestoreResponse, error::Error as FvsError, +}; +use std::path::Path; +use tokio::sync::watch; + +/// Identifies rollback checkpoints that must not appear as user snapshots. +/// +/// Snapshot filtering compares this persisted value exactly, so changing it +/// would expose checkpoints created by older versions. +pub(crate) const AUTO_CHECKPOINT_MESSAGE: &str = "bottles-next:auto-checkpoint"; + +impl From<&FvsProgress> for Transfer { + fn from(progress: &FvsProgress) -> Self { + // FVS uses negative counters when progress is unavailable. Core progress + // uses unsigned values and represents an unavailable total explicitly. + Self { + current: progress.current.try_into().unwrap_or_default(), + total: progress.total.try_into().ok().filter(|total| *total > 0), + } + } +} + +pub(crate) fn repository(root: &Path) -> Repository { + Repository { + repository_path: root.display().to_string(), + block_size: FVS_BLOCK_SIZE, + } +} + +pub(crate) async fn capture( + root: &Path, + message: String, + stage: Stage, + cx: &Context, + progress: &watch::Sender>, +) -> Result { + for directory in ["prefix", "upper"] { + crate::winebridge::WineBridgeClient::clear_discovery(&root.join(directory)).await?; + } + let client = cx.fvs().await?; + if !crate::utils::exists(&root.join(".fvs2")).await? { + client.new_repository(root, FVS_BLOCK_SIZE).await?; + } + let stream = client.commit_stream(&repository(root), message).await?; + finish_commit(stream, |event| { + progress.send_replace(Some(Progress::transferring(stage.clone(), event.into()))); + }) + .await +} + +pub(crate) async fn restore( + root: &Path, + revision: &str, + cx: &Context, + progress: &watch::Sender>, +) -> Result { + let stream = cx + .fvs() + .await? + .restore_stream(&repository(root), revision, None::<&Path>, true, false) + .await?; + finish_restore(stream, |event| { + progress.send_replace(Some(Progress::transferring(Stage::Restoring, event.into()))); + }) + .await +} + +/// Restore both data and configuration on a rejected mutation. Nothing is published +/// before this succeeds; a failed rollback names the owner requiring repair. +pub(crate) async fn recover( + result: Result, + root: &Path, + checkpoint: &Commit, + cx: &Context, + progress: &watch::Sender>, +) -> Result { + if let Err(error) = &result { + if let Err(source) = restore(root, &checkpoint.state_id, cx, progress).await { + tracing::error!(%error, "mutation failed before rollback failed"); + return Err(super::EnvironmentError::Rollback { + root: root.to_path_buf(), + source: Box::new(source), + } + .into()); + } + } + result +} + +/// Drains an FVS commit stream, forwarding every frame and requiring a terminal commit. +async fn finish_commit( + stream: impl Stream>, + on_progress: impl FnMut(&FvsProgress), +) -> Result { + finish_stream( + stream, + on_progress, + |progress| progress.result_commit, + "commit", + ) + .await +} + +/// Drains an FVS restore stream, forwarding every frame and requiring a terminal result. +async fn finish_restore( + stream: impl Stream>, + on_progress: impl FnMut(&FvsProgress), +) -> Result { + finish_stream( + stream, + on_progress, + |progress| progress.result_restore, + "restore", + ) + .await +} + +/// Consumes the FVS streaming protocol and extracts its terminal payload. +/// +/// Every frame, including the terminal frame, is forwarded to `on_progress`. +/// End-of-stream or a terminal frame without the expected payload is a protocol +/// error rather than successful completion. +async fn finish_stream( + stream: impl Stream>, + mut on_progress: impl FnMut(&FvsProgress), + mut result: impl FnMut(FvsProgress) -> Option, + operation: &'static str, +) -> Result { + futures_util::pin_mut!(stream); + while let Some(progress) = stream.try_next().await? { + on_progress(&progress); + if progress.done { + return result(progress).ok_or(FvsError::MissingStreamResult(operation).into()); + } + } + Err(FvsError::MissingStreamResult(operation).into()) +} + +#[cfg(test)] +mod fvs_tests { + use futures_util::stream; + + use super::*; + + #[test] + fn finish_commit_forwards_progress_and_returns_terminal_result() { + futures_lite::future::block_on(async { + let frames = [ + FvsProgress { + phase: "hashing".into(), + current: 1, + total: 2, + ..Default::default() + }, + FvsProgress { + phase: "indexing".into(), + current: -1, + total: -1, + ..Default::default() + }, + FvsProgress { + phase: "done".into(), + done: true, + result_commit: Some(Commit { + state_id: "checkpoint".into(), + ..Default::default() + }), + ..Default::default() + }, + ]; + let mut updates = Vec::new(); + + let commit = finish_commit(stream::iter(frames.map(Ok::<_, FvsError>)), |progress| { + updates.push(Transfer::from(progress)) + }) + .await + .unwrap(); + + assert_eq!( + updates, + [ + Transfer { + current: 1, + total: Some(2), + }, + Transfer { + current: 0, + total: None, + }, + Transfer { + current: 0, + total: None, + }, + ] + ); + assert_eq!(commit.state_id, "checkpoint"); + }); + } +} diff --git a/src/environment/mod.rs b/src/environment/mod.rs index b67f262..a1b956a 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -2,13 +2,17 @@ #[cfg(feature = "fvs")] pub(crate) mod artifacts; +#[cfg(feature = "fvs")] +pub(crate) mod history; +#[cfg(feature = "fvs")] +pub(crate) mod registry; mod config; mod error; pub(crate) mod prefix; mod software; -pub(crate) use software::reconcile; +pub(crate) use software::{reconcile, validate_edit}; use std::path::Path; @@ -34,7 +38,7 @@ pub(crate) struct Environment { } impl Environment { - /// Resolves layers at creation or when the stopped owner changes runners. + /// Derives the immutable stack from configuration while the owner is stopped. pub(crate) async fn prepare( config: &mut EnvironmentConfig, runner: &dyn Runner, @@ -43,11 +47,13 @@ impl Environment { cancellation: &CancellationToken, ) -> Result<()> { let _ = (runner, addons, cx, cancellation); + #[cfg(feature = "fvs")] + let ids: Vec<_> = config.ordered_addons().collect(); match &mut config.storage { Storage::Standard => Ok(()), #[cfg(feature = "fvs")] Storage::Virgo { layers } => { - let base = artifacts::base_layers( + let mut base = artifacts::base_layers( runner, &config.components[&crate::Slot::Runner].id().to_string(), addons, @@ -55,7 +61,10 @@ impl Environment { cancellation, ) .await?; - layers.splice(..layers.len().min(2), base); + for id in ids { + base.push(artifacts::cache::layer(id, cx).await?); + } + *layers = base; Ok(()) } } @@ -86,10 +95,12 @@ impl Environment { ) -> Result { prefix::prepare(&config.storage, root, cx).await?; let prefix = root.join("prefix"); - let command = config.wrappers.apply( - WineBridgeClient::command(runner, &prefix, config.winebridge().path(cx.directories())) - .envs(config.env_vars.iter()), - ); + let command = config.wrappers.apply(WineBridgeClient::command( + runner, + &prefix, + config.winebridge().path(cx.directories()), + config.effective_env_vars(), + )); let bridge = match WineBridgeClient::connect_or_spawn(&prefix, command).await { Ok(bridge) => bridge, Err(error) => { diff --git a/src/environment/prefix/mod.rs b/src/environment/prefix/mod.rs index 8552c37..7cefe95 100644 --- a/src/environment/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -1,4 +1,4 @@ -//! Prefix storage backends and FVS history primitives. +//! Prefix storage backends. //! //! Standard storage mutates a conventional prefix directly; Virgo stores an //! ordered FVS layer stack with a private writable upper directory. Virgo addon @@ -12,24 +12,12 @@ use std::path::Path; #[cfg(feature = "fvs")] pub use virgo::VirgoError; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; #[cfg(feature = "fvs")] -use { - crate::Transfer, - futures_core::Stream, - futures_util::TryStreamExt, - fvs_rs::{Commit, Layer, Progress as FvsProgress, RestoreResponse, error::Error as FvsError}, -}; +use fvs_rs::Layer; +use serde::{Deserialize, Serialize}; use crate::{Context, error::Result}; -/// Identifies rollback checkpoints that must not appear as user snapshots. -/// -/// Snapshot filtering compares this persisted value exactly, so changing it -/// would expose checkpoints created by older versions. -#[cfg(feature = "fvs")] -pub(crate) const AUTO_CHECKPOINT_MESSAGE: &str = "bottles-next:auto-checkpoint"; #[cfg(feature = "fvs")] pub(crate) const FVS_BLOCK_SIZE: u32 = 1024 * 1024; @@ -45,24 +33,12 @@ pub enum Storage { /// Virgo is experimental and requires the configured FVS service. #[cfg(feature = "fvs")] Virgo { - /// Resolved layer order retained until composition is derived from settings. + /// Exact immutable revisions derived from the environment selections. #[serde(default)] layers: Vec, }, } -#[cfg(feature = "fvs")] -impl From<&FvsProgress> for Transfer { - fn from(progress: &FvsProgress) -> Self { - // FVS uses negative counters when progress is unavailable. Core progress - // uses unsigned values and represents an unavailable total explicitly. - Self { - current: progress.current.try_into().unwrap_or_default(), - total: progress.total.try_into().ok().filter(|total| *total > 0), - } - } -} - /// Creates storage at an explicit owner location. pub(crate) async fn create(storage: &Storage, root: &Path) -> Result<()> { let directory = match storage { @@ -90,153 +66,3 @@ pub(crate) async fn stop(storage: &Storage, root: &Path, context: &Context) -> R Storage::Virgo { .. } => virgo::stop(root, context).await, } } - -/// Applies one addon to storage; the enclosing software workflow owns cleanup. -pub(crate) async fn install( - storage: &mut Storage, - root: &Path, - item_id: Uuid, - replaced_id: Option, - execute: impl for<'a> std::ops::AsyncFnOnce(&'a Path) -> Result<()>, - context: &Context, -) -> Result<()> { - let _ = (item_id, replaced_id, context); - match storage { - Storage::Standard => execute(&root.join("prefix")).await, - #[cfg(feature = "fvs")] - Storage::Virgo { layers } => { - virgo::install(root, layers, item_id, replaced_id, context).await - } - } -} - -pub(crate) async fn uninstall( - storage: &mut Storage, - root: &Path, - item_id: Uuid, - execute: impl for<'a> std::ops::AsyncFnOnce(&'a Path, bool) -> Result<()>, - context: &Context, -) -> Result<()> { - let _ = (item_id, context); - match storage { - Storage::Standard => execute(&root.join("prefix"), true).await, - #[cfg(feature = "fvs")] - Storage::Virgo { layers } => { - virgo::uninstall(root, layers, item_id, execute, context).await - } - } -} - -/// Drains an FVS commit stream, forwarding every frame and requiring a terminal commit. -#[cfg(feature = "fvs")] -pub(crate) async fn finish_commit( - stream: impl Stream>, - on_progress: impl FnMut(&FvsProgress), -) -> Result { - finish_stream( - stream, - on_progress, - |progress| progress.result_commit, - "commit", - ) - .await -} - -/// Drains an FVS restore stream, forwarding every frame and requiring a terminal result. -#[cfg(feature = "fvs")] -pub(crate) async fn finish_restore( - stream: impl Stream>, - on_progress: impl FnMut(&FvsProgress), -) -> Result { - finish_stream( - stream, - on_progress, - |progress| progress.result_restore, - "restore", - ) - .await -} - -/// Consumes the FVS streaming protocol and extracts its terminal payload. -/// -/// Every frame, including the terminal frame, is forwarded to `on_progress`. -/// End-of-stream or a terminal frame without the expected payload is a protocol -/// error rather than successful completion. -#[cfg(feature = "fvs")] -async fn finish_stream( - stream: impl Stream>, - mut on_progress: impl FnMut(&FvsProgress), - mut result: impl FnMut(FvsProgress) -> Option, - operation: &'static str, -) -> Result { - futures_util::pin_mut!(stream); - while let Some(progress) = stream.try_next().await? { - on_progress(&progress); - if progress.done { - return result(progress).ok_or(FvsError::MissingStreamResult(operation).into()); - } - } - Err(FvsError::MissingStreamResult(operation).into()) -} - -#[cfg(all(test, feature = "fvs"))] -mod fvs_tests { - use futures_util::stream; - - use super::*; - - #[test] - fn finish_commit_forwards_progress_and_returns_terminal_result() { - futures_lite::future::block_on(async { - let frames = [ - FvsProgress { - phase: "hashing".into(), - current: 1, - total: 2, - ..Default::default() - }, - FvsProgress { - phase: "indexing".into(), - current: -1, - total: -1, - ..Default::default() - }, - FvsProgress { - phase: "done".into(), - done: true, - result_commit: Some(Commit { - state_id: "checkpoint".into(), - ..Default::default() - }), - ..Default::default() - }, - ]; - let mut updates = Vec::new(); - - let commit = finish_commit(stream::iter(frames.map(Ok::<_, FvsError>)), |progress| { - updates.push(Transfer::from(progress)) - }) - .await - .unwrap(); - - assert_eq!( - updates, - [ - Transfer { - current: 1, - total: Some(2), - }, - Transfer { - current: 0, - total: None, - }, - Transfer { - current: 0, - total: None, - }, - ] - ); - assert_eq!(commit.state_id, "checkpoint"); - }); - } -} diff --git a/src/environment/prefix/virgo/mod.rs b/src/environment/prefix/virgo/mod.rs index 94eeca3..7cd51a5 100644 --- a/src/environment/prefix/virgo/mod.rs +++ b/src/environment/prefix/virgo/mod.rs @@ -5,16 +5,10 @@ //! persisted by the owner and must be changed only while the owner is //! stopped. -use crate::environment::artifacts::cache; - -use std::{ - ops::AsyncFnOnce, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; use futures_lite::StreamExt; use fvs_rs::{Layer, UnmountMode}; -use uuid::Uuid; use crate::{Context, error::Result}; @@ -95,41 +89,6 @@ pub(super) async fn stop(root: &Path, context: &Context) -> Result<()> { Ok(()) } -pub(super) async fn install( - root: &Path, - layers: &mut Vec, - item_id: Uuid, - replaced_id: Option, - context: &Context, -) -> Result<()> { - let cached = cache::layer(item_id, context).await?; - if let Some(id) = replaced_id { - cache::remove(layers, id, context); - } - cache::remove(layers, item_id, context); - layers.push(cached); - prepare(root, layers, context).await?; - cache::apply_registry(&root.join("prefix"), item_id, context).await -} - -pub(super) async fn uninstall( - root: &Path, - layers: &mut Vec, - item_id: Uuid, - execute: F, - context: &Context, -) -> Result<()> -where - F: for<'a> AsyncFnOnce(&'a Path, bool) -> Result<()>, -{ - // Removing the layer reveals the previous filesystem contents, so the recipe - // must not restore overwritten files into the writable upper directory. - cache::remove(layers, item_id, context); - prepare(root, layers, context).await?; - // The enclosing transaction shuts Wine down and unmounts before rollback. - execute(&root.join("prefix"), false).await -} - /// Refuses to mount over existing contents, which would otherwise be hidden. async fn ensure_empty_dir(path: &Path) -> Result<()> { async_fs::create_dir_all(path).await?; diff --git a/src/environment/registry.rs b/src/environment/registry.rs new file mode 100644 index 0000000..31b402f --- /dev/null +++ b/src/environment/registry.rs @@ -0,0 +1,127 @@ +//! Compose the managed registry baseline, then replay private changes over it. + +use super::{artifacts::cache, prefix::VirgoError}; +use crate::{ + Context, + error::{Error, Result}, +}; +use fvs_rs::{Layer, UnmountMode}; +use regdiff_rs::prelude::{Diff, Hive, Registry, apply_files}; +use std::{fs, path::Path}; +use uuid::Uuid; + +pub(crate) fn registry_files() -> [(&'static str, Hive); 2] { + [ + ("user.reg", Hive::CurrentUser), + ("system.reg", Hive::LocalMachine), + ] +} + +pub(crate) fn write_forward(old: &Path, new: &Path, output: &Path, hive: Hive) -> Result<()> { + let old = + Registry::try_from(old, hive).map_err(|error| VirgoError::Registry(error.to_string()))?; + let new = + Registry::try_from(new, hive).map_err(|error| VirgoError::Registry(error.to_string()))?; + Registry::diff(&old, &new) + .serialize_file(output) + .map_err(|error| VirgoError::Registry(error.to_string()))?; + Ok(()) +} + +fn merge_private(previous: &Path, upper: &Path, baseline: &Path, merged: &Path) -> Result<()> { + fs::create_dir_all(merged)?; + for (file, hive) in registry_files() { + if previous.is_dir() { + let patch = merged.join(format!("{file}.patch")); + let current = upper.join(file); + let empty = merged.join("empty.reg"); + if !current.is_file() { + fs::write(&empty, "WINE REGISTRY Version 2\n\n")?; + } + write_forward( + &previous.join(file), + if current.is_file() { ¤t } else { &empty }, + &patch, + hive, + )?; + apply_files(baseline.join(file), patch.clone(), merged.join(file), hive) + .map_err(|error| VirgoError::Registry(error.to_string()))?; + fs::remove_file(patch)?; + if empty.exists() { + fs::remove_file(empty)?; + } + } else { + fs::copy(baseline.join(file), merged.join(file))?; + } + } + Ok(()) +} + +/// The owner is stopped and checkpointed. Only managed registry files are replaced; +/// all other private files and whiteouts keep normal overlay precedence. +pub(crate) async fn compose( + root: &Path, + layers: &[Layer], + addons: &[Uuid], + cx: &Context, +) -> Result<()> { + let stage = cx + .directories() + .data_dir() + .join("virgo/.staging") + .join(Uuid::new_v4().to_string()); + let prefix = stage.join("prefix"); + let baseline = stage.join("baseline"); + async_fs::create_dir_all(&prefix).await?; + async_fs::create_dir_all(&baseline).await?; + let client = cx.fvs().await?; + let mount = client + .mount(&prefix, layers.to_vec(), None::<&Path>) + .await?; + let copied = async { + for (file, _) in registry_files() { + async_fs::copy(prefix.join(file), baseline.join(file)).await?; + } + Ok::<_, Error>(()) + } + .await; + // This scratch mount never contains the owner's upper. Release it before + // changing owner data; on failure retain the mountpoint for explicit cleanup. + client + .unmount(&mount, UnmountMode::Normal) + .await + .map_err(|source| super::EnvironmentError::Cleanup { + prefix, + source: Box::new(source.into()), + })?; + let result = async { + copied?; + for id in addons { + cache::apply_registry(&baseline, *id, cx).await?; + } + let root = root.to_path_buf(); + let stage = stage.clone(); + blocking::unblock(move || { + let previous = root.join("registry-baseline"); + let upper = root.join("upper"); + let merged = stage.join("merged"); + merge_private(&previous, &upper, &baseline, &merged)?; + for (file, _) in registry_files() { + fs::rename(merged.join(file), upper.join(file))?; + let whiteout = upper.join(format!(".wh.{file}")); + if whiteout.exists() { + fs::remove_file(whiteout)?; + } + } + if previous.exists() { + fs::remove_dir_all(&previous)?; + } + fs::rename(baseline, previous)?; + Ok::<_, Error>(()) + }) + .await + } + .await; + let _ = async_fs::remove_dir_all(stage).await; + result +} diff --git a/src/environment/software.rs b/src/environment/software.rs index 75e547d..7e6f186 100644 --- a/src/environment/software.rs +++ b/src/environment/software.rs @@ -8,47 +8,43 @@ use tokio_util::sync::CancellationToken; #[cfg(feature = "fvs")] use super::Storage; -use super::{EnvironmentConfig, EnvironmentError, prefix}; +use super::{EnvironmentConfig, EnvironmentError}; use crate::{ Addon, AddonError, Addons, Context, Progress, Slot, Stage, - addons::{InstallInputs, execute, replay_env_vars, uninstall}, - error::{Error, Result}, + addons::{InstallInputs, execute, uninstall}, + error::Result, }; -/// Called while the owner is coordinated and stopped. The owner publishes -/// only after all prefix work succeeds. Existing dependencies remain installed. -pub(crate) async fn reconcile( +/// Validate selections before lifecycle or checkpoint work; report whether reconciliation is needed. +pub(crate) fn validate_edit( previous: &EnvironmentConfig, - candidate: &mut EnvironmentConfig, - root: &Path, - cx: &Context, + candidate: &EnvironmentConfig, addons: &Addons, - progress: &watch::Sender>, - cancellation: &CancellationToken, -) -> Result<()> { +) -> Result { if candidate.storage != previous.storage { return Err(EnvironmentError::InvalidEdit( - "storage strategy and resolved layers are managed at creation and preparation", + "storage strategy and resolved layers are managed by the environment", ) .into()); } - if !candidate.dependencies.starts_with(&previous.dependencies) { + if matches!(candidate.storage, super::Storage::Standard) + && !candidate.dependencies.starts_with(&previous.dependencies) + { return Err(EnvironmentError::InvalidEdit( "installed dependencies cannot be removed, replaced or reordered", ) .into()); } - // Resolve every changed selection before any prefix work. Validation - // above this layer sees the final batch, never intermediate selections. - let mut removals = Vec::new(); - let mut installations = Vec::new(); + let mut prefix_changed = candidate.dependencies != previous.dependencies; + for slot in Slot::iter() { let old = previous.component(slot); let new = candidate.component(slot); if old == new { continue; } + prefix_changed |= !slot.is_runtime() || slot == Slot::Runner; if let Some(new) = new { let downloaded = addons .component(new.id()) @@ -59,30 +55,23 @@ pub(crate) async fn reconcile( ) .into()); } - if !slot.is_runtime() { - installations.push(( - new.id(), - old.map(Addon::id), - vec![downloaded.artifact(cx.directories())], - )); - } - } else if let Some(old) = old.filter(|_| !slot.is_runtime()) { - removals.push((old.id(), vec![old.artifact(cx.directories())])); } } - for new in &candidate.dependencies[previous.dependencies.len()..] { - if previous.dependency(new.id()).is_some() - || candidate - .dependencies - .iter() - .filter(|addon| addon.id() == new.id()) - .count() - != 1 + for new in &candidate.dependencies { + if candidate + .dependencies + .iter() + .filter(|addon| addon.id() == new.id()) + .count() + != 1 { return Err( EnvironmentError::InvalidEdit("a dependency may only be selected once").into(), ); } + if previous.dependency(new.id()) == Some(new) { + continue; + } let downloaded = addons .dependency(new.id()) .ok_or(AddonError::NotFound(new.id()))?; @@ -92,177 +81,102 @@ pub(crate) async fn reconcile( ) .into()); } - let resources = downloaded.resources(cx.directories()); - installations.push((new.id(), None, resources)); } - let runner_changed = candidate.runner() != previous.runner(); - if !runner_changed && removals.is_empty() && installations.is_empty() { - return Ok(()); - } - super::Environment::stop(previous, root, cx).await?; + Ok(prefix_changed) +} + +/// Apply a validated edit that needs prefix work. The owner is stopped and +/// checkpoints Virgo before calling; configuration is saved before publication. +pub(crate) async fn reconcile( + previous: &EnvironmentConfig, + candidate: &mut EnvironmentConfig, + root: &Path, + cx: &Context, + addons: &Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, +) -> Result<()> { let runner = candidate .runner() .load_runner(cx.directories(), candidate.umu()) .await?; #[cfg(feature = "fvs")] if matches!(candidate.storage, Storage::Virgo { .. }) { - for (id, _, _) in &installations { - super::artifacts::prepare_addon(*id, candidate, addons, cx, progress, cancellation) + for id in candidate.ordered_addons() { + super::artifacts::prepare_addon(id, candidate, addons, cx, progress, cancellation) .await?; } - } - if runner_changed { + candidate.resolve_env_vars(addons, cx)?; super::Environment::prepare(candidate, runner.as_ref(), addons, cx, cancellation).await?; + let ids: Vec<_> = candidate.ordered_addons().collect(); + if let Storage::Virgo { layers } = &candidate.storage { + super::registry::compose(root, layers, &ids, cx).await?; + } + return Ok(()); + } + let mut removals = Vec::new(); + let mut installations = Vec::new(); + for slot in Slot::iter().filter(|slot| !slot.is_runtime()) { + let old = previous.component(slot); + let new = candidate.component(slot); + if old == new { + continue; + } + if let Some(new) = new { + installations.push(vec![new.artifact(cx.directories())]); + } else if let Some(old) = old { + removals.push((old.id(), vec![old.artifact(cx.directories())])); + } + } + for new in &candidate.dependencies[previous.dependencies.len()..] { + let downloaded = addons + .dependency(new.id()) + .ok_or(AddonError::NotFound(new.id()))?; + installations.push(downloaded.resources(cx.directories())); } + let prefix = root.join("prefix"); let winebridge = candidate.winebridge().path(cx.directories()); - - for (id, resources) in &removals { - transact( - candidate, - root, - cx, + let mut env_vars = candidate.addon_env_vars.clone(); + for (id, resources) in removals { + let result = uninstall( + InstallInputs { + prefix: &prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars: &mut env_vars, + explicit_env_vars: &candidate.env_vars, + }, + &resources, + id, cancellation, - progress, - async |config| { - prefix::uninstall( - &mut config.storage, - root, - *id, - async |prefix, restore_files| { - uninstall( - InstallInputs { - prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars: &mut config.env_vars, - }, - resources, - restore_files, - *id, - cancellation, - |_| { - progress.send_replace(Some(Progress::new(Stage::Removing))); - }, - ) - .await - }, - cx, - ) - .await + |_| { + progress.send_replace(Some(Progress::new(Stage::Removing))); }, ) - .await?; + .await; + super::Environment::stop(candidate, root, cx).await?; + result?; } - for (id, replaced, resources) in installations { - transact( - candidate, - root, - cx, + for resources in installations { + let result = execute( + InstallInputs { + prefix: &prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars: &mut env_vars, + explicit_env_vars: &candidate.env_vars, + }, + &resources, cancellation, - progress, - async |config| { - prefix::install( - &mut config.storage, - root, - id, - replaced, - async |prefix| { - execute( - InstallInputs { - prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars: &mut config.env_vars, - }, - &resources, - cancellation, - |_| { - progress.send_replace(Some(Progress::new(Stage::Configuring))); - }, - ) - .await - }, - cx, - ) - .await + |_| { + progress.send_replace(Some(Progress::new(Stage::Configuring))); }, ) - .await?; - replay_env_vars(&mut candidate.env_vars, &resources); - } - Ok(()) -} - -/// Coordinates one addon mutation. Only Virgo uses automatic checkpoints; -/// Standard keeps direct writes. Owner configuration is saved separately. -/// Failed shutdown/unmount returns before any rollback can touch live storage. -async fn transact( - config: &mut EnvironmentConfig, - root: &Path, - cx: &Context, - cancellation: &CancellationToken, - progress: &watch::Sender>, - work: impl for<'a> std::ops::AsyncFnOnce(&'a mut EnvironmentConfig) -> Result<()>, -) -> Result<()> { - #[cfg(feature = "fvs")] - let repository = fvs_rs::Repository { - repository_path: root.display().to_string(), - block_size: prefix::FVS_BLOCK_SIZE, - }; - #[cfg(feature = "fvs")] - let checkpoint = if matches!(config.storage, Storage::Virgo { .. }) { - let stream = cx - .fvs() - .await? - .commit_stream(&repository, prefix::AUTO_CHECKPOINT_MESSAGE.into()) - .await?; - Some( - prefix::finish_commit(stream, |event| { - progress.send_replace(Some(Progress::transferring( - Stage::Checkpointing, - event.into(), - ))); - }) - .await?, - ) - } else { - None - }; - let _ = progress; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - let result = work(config).await; - super::Environment::stop(config, root, cx).await?; - let result = if result.is_ok() && cancellation.is_cancelled() { - Err(Error::Cancelled) - } else { - result - }; - #[cfg(feature = "fvs")] - if let (Err(error), Some(checkpoint)) = (&result, checkpoint) { - let restored = async { - let stream = cx - .fvs() - .await? - .restore_stream( - &repository, - &checkpoint.state_id, - None::<&Path>, - true, - false, - ) - .await?; - prefix::finish_restore(stream, |event| { - progress.send_replace(Some(Progress::transferring(Stage::Restoring, event.into()))); - }) - .await - } .await; - if let Err(failed) = restored { - tracing::error!(%failed, "prefix rollback failed after {error}"); - } + super::Environment::stop(candidate, root, cx).await?; + result?; } - result + candidate.resolve_env_vars(addons, cx)?; + Ok(()) } diff --git a/src/runner/mod.rs b/src/runner/mod.rs index b2fd750..2652a1e 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -16,7 +16,6 @@ pub(crate) use proton::Proton; pub(crate) use wine::Wine; use std::{ - ffi::OsStr, path::{Path, PathBuf}, process::ExitStatus, }; @@ -63,14 +62,6 @@ impl RunnerCommand { pub(crate) fn wrapped_by(self, wrapper: impl Wrapper) -> Self { Self(wrapper.wrap(self.0).into()) } - - pub(crate) fn envs, V: AsRef>( - mut self, - envs: impl IntoIterator, - ) -> Self { - self.0 = self.0.envs(envs); - self - } } impl From for Command { diff --git a/src/runner/proton.rs b/src/runner/proton.rs index 5a30056..a58cbbe 100644 --- a/src/runner/proton.rs +++ b/src/runner/proton.rs @@ -29,13 +29,12 @@ impl Proton { #[async_trait] impl Runner for Proton { fn command(&self, prefix: &Path, inner: Command) -> RunnerCommand { + let command: Command = Command::new(&self.umu_executable).wrap(inner).into(); RunnerCommand( - Command::new(&self.umu_executable) + command .env("WINEPREFIX", prefix) .env("WINEARCH", "win64") - .env("PROTONPATH", &self.proton_path) - .wrap(inner) - .into(), + .env("PROTONPATH", &self.proton_path), ) } @@ -46,11 +45,17 @@ impl Runner for Proton { /// /// See . async fn wineserver(&self, prefix: &Path, arg: &str) -> Result<()> { - let command = Command::new(self.proton_path.join("files/bin/wineserver")) - .arg(arg) - .env("PROTONPATH", "umu-sniper"); - - let status = self.command(prefix, command).spawn()?.status().await?; + let status = RunnerCommand( + Command::new(&self.umu_executable) + .arg(self.proton_path.join("files/bin/wineserver")) + .arg(arg) + .env("WINEPREFIX", prefix) + .env("WINEARCH", "win64") + .env("PROTONPATH", "umu-sniper"), + ) + .spawn()? + .status() + .await?; if status.success() || (arg == "-k" && status.code() == Some(1)) { return Ok(()); diff --git a/src/runner/wine.rs b/src/runner/wine.rs index bea8f12..46d7b93 100644 --- a/src/runner/wine.rs +++ b/src/runner/wine.rs @@ -25,13 +25,8 @@ impl Wine { #[async_trait] impl Runner for Wine { fn command(&self, prefix: &Path, inner: Command) -> RunnerCommand { - RunnerCommand( - Command::new(&self.executable) - .env("WINEPREFIX", prefix) - .env("WINEARCH", "win64") - .wrap(inner) - .into(), - ) + let command: Command = Command::new(&self.executable).wrap(inner).into(); + RunnerCommand(command.env("WINEPREFIX", prefix).env("WINEARCH", "win64")) } async fn wineserver(&self, prefix: &Path, arg: &str) -> Result<()> { diff --git a/src/winebridge.rs b/src/winebridge.rs index 8936dde..159d151 100644 --- a/src/winebridge.rs +++ b/src/winebridge.rs @@ -81,17 +81,20 @@ pub(crate) struct WineBridgeClient { } impl WineBridgeClient { - pub(crate) fn command( + pub(crate) fn command<'a>( runner: &dyn Runner, prefix: &Path, winebridge_root: impl AsRef, + env_vars: impl IntoIterator, ) -> RunnerCommand { runner.command( prefix, - Command::new(winebridge_root.as_ref().join("bottles-winebridge.exe")).env( - "WINEBRIDGE_PORT_FILE", - format!(r"C:\windows\temp\{PORT_FILE_NAME}"), - ), + Command::new(winebridge_root.as_ref().join("bottles-winebridge.exe")) + .envs(env_vars) + .env( + "WINEBRIDGE_PORT_FILE", + format!(r"C:\windows\temp\{PORT_FILE_NAME}"), + ), ) } From f05e7185b9ca70cbf55b819bd80a633d7f43a443 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Fri, 11 Sep 2026 13:04:34 +0530 Subject: [PATCH 11/24] refactor(core): derive addon environment variables on demand --- README.md | 7 +++++-- src/addons/installer/engine.rs | 14 ++++++------- src/addons/mod.rs | 2 +- src/bottle/manager.rs | 1 - src/bottle/software.rs | 9 ++++++++- src/environment/artifacts/mod.rs | 1 - src/environment/artifacts/software.rs | 8 ++++++-- src/environment/config.rs | 29 +++++++++++---------------- src/environment/mod.rs | 4 +++- src/environment/software.rs | 4 +--- 10 files changed, 42 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 1e5c84e..691967c 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,11 @@ or removing layers leaves private files, updates, saves, and whiteouts intact. Cached registry patches are applied in that same order. The owner retains a managed registry baseline and reapplies private registry changes after rebuilding it. -Recipe-generated environment variables are persisted separately from explicit -settings. Explicit settings take precedence; execution-owned variables such as +Recipe environment variables are derived when starting an environment or running +Standard installers; only explicit settings are persisted. Component recipes are +built in, and dependency recipes come from the UUID-pinned local index, not the +current catalog. Missing dependency recipe metadata prevents startup. Explicit +settings take precedence; execution-owned variables such as `WINEPREFIX`, `WINEARCH`, and `PROTONPATH` are applied last. Snapshots capture owner metadata, exact layer references, registry baseline, and private prefix data while stopped, without WineBridge discovery files. diff --git a/src/addons/installer/engine.rs b/src/addons/installer/engine.rs index 85e0b74..865cc4a 100644 --- a/src/addons/installer/engine.rs +++ b/src/addons/installer/engine.rs @@ -104,14 +104,12 @@ pub(crate) async fn uninstall( Ok(()) } -/// Ensures environment changes are applied when prefix storage reuses an existing addon layer. -/// -/// A cached Virgo layer can complete installation without executing the recipe, -/// so its [`InstallStep::SetEnvironment`] steps would otherwise be absent from -/// the bottle's in-memory state. Replaying is idempotent when the recipe did run; -/// later entries with the same name overwrite earlier ones. -pub(crate) fn replay_env_vars(env_vars: &mut EnvVars, resources: &[Artifact]) { - for step in resources.iter().flat_map(|resource| &resource.steps) { +/// Collects recipe variables in declaration order; later values override earlier ones. +pub(crate) fn replay_env_vars<'a>( + env_vars: &mut EnvVars, + steps: impl IntoIterator, +) { + for step in steps { if let InstallStep::SetEnvironment { name, value } = step { env_vars.insert(name.clone(), value.clone()); } diff --git a/src/addons/mod.rs b/src/addons/mod.rs index 5ebc509..16b8c0a 100644 --- a/src/addons/mod.rs +++ b/src/addons/mod.rs @@ -32,7 +32,7 @@ pub use error::{AddonError, CatalogError, InstallerError}; pub use index::IndexEntry; #[cfg(feature = "fvs")] pub(crate) use installer::Artifact; -pub(crate) use installer::{InstallInputs, execute, replay_env_vars, uninstall}; +pub(crate) use installer::{InstallInputs, execute, recipe_steps, replay_env_vars, uninstall}; pub use manager::Addons; /// Rejects empty or whitespace-only input without trimming accepted values. diff --git a/src/bottle/manager.rs b/src/bottle/manager.rs index 1d416be..c0a4cf7 100644 --- a/src/bottle/manager.rs +++ b/src/bottle/manager.rs @@ -223,7 +223,6 @@ impl BottleManager { components, dependencies: Vec::new(), env_vars: Default::default(), - addon_env_vars: Default::default(), wrappers: Default::default(), }; #[cfg(feature = "fvs")] diff --git a/src/bottle/software.rs b/src/bottle/software.rs index be87816..971c6d5 100644 --- a/src/bottle/software.rs +++ b/src/bottle/software.rs @@ -207,7 +207,14 @@ impl Bottle { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - Environment::start(&state.environment, runner.as_ref(), &root, &self.0.cx).await + Environment::start( + &state.environment, + runner.as_ref(), + &root, + &self.0.cx, + &self.0.addons, + ) + .await } async fn with_environment(&self, work: F) -> Result diff --git a/src/environment/artifacts/mod.rs b/src/environment/artifacts/mod.rs index de2079e..e18affa 100644 --- a/src/environment/artifacts/mod.rs +++ b/src/environment/artifacts/mod.rs @@ -310,7 +310,6 @@ mod tests { components: Default::default(), dependencies: vec![], env_vars: Default::default(), - addon_env_vars: Default::default(), wrappers: Default::default(), }; let (progress, _) = tokio::sync::watch::channel(None); diff --git a/src/environment/artifacts/software.rs b/src/environment/artifacts/software.rs index bd555a7..b79a2d8 100644 --- a/src/environment/artifacts/software.rs +++ b/src/environment/artifacts/software.rs @@ -119,7 +119,12 @@ pub(crate) async fn prepare_addon( let mut env_vars = EnvVars::default(); for prerequisite in &required { layers.push(cache::layer(*prerequisite, cx).await?); - replay_env_vars(&mut env_vars, &resources(*prerequisite, addons, cx)?); + replay_env_vars( + &mut env_vars, + resources(*prerequisite, addons, cx)? + .iter() + .flat_map(|resource| &resource.steps), + ); } let resources = resources(id, addons, cx)?; cache::install( @@ -183,7 +188,6 @@ mod tests { dependency(ids[3], vec![]), ], env_vars: Default::default(), - addon_env_vars: Default::default(), wrappers: Default::default(), }; let mut order = Vec::new(); diff --git a/src/environment/config.rs b/src/environment/config.rs index 8690bde..25c6c7d 100644 --- a/src/environment/config.rs +++ b/src/environment/config.rs @@ -18,9 +18,6 @@ pub struct EnvironmentConfig { pub dependencies: Vec>, #[serde(default, skip_serializing_if = "EnvVars::is_empty")] pub env_vars: EnvVars, - /// Derived recipe contributions, kept separate from explicit user settings. - #[serde(default, skip_serializing_if = "EnvVars::is_empty")] - pub(crate) addon_env_vars: EnvVars, #[serde(default)] pub wrappers: Wrappers, } @@ -35,29 +32,27 @@ impl EnvironmentConfig { .chain(self.dependencies.iter().map(Addon::id)) } - pub(crate) fn effective_env_vars(&self) -> impl Iterator { - self.addon_env_vars.iter().chain(self.env_vars.iter()) - } - - pub(crate) fn resolve_env_vars( - &mut self, - addons: &crate::Addons, - cx: &crate::Context, - ) -> Result<()> { + /// Derives recipe variables from selections and UUID-pinned local dependency recipes. + pub(crate) fn addon_env_vars(&self, addons: &crate::Addons) -> Result { let mut vars = EnvVars::default(); for slot in Slot::iter().filter(|slot| !slot.is_runtime()) { - if let Some(addon) = self.component(slot) { - crate::addons::replay_env_vars(&mut vars, &[addon.artifact(cx.directories())]); + if self.component(slot).is_some() { + crate::addons::replay_env_vars(&mut vars, crate::addons::recipe_steps(slot)); } } for addon in &self.dependencies { let entry = addons .dependency(addon.id()) .ok_or(crate::AddonError::NotFound(addon.id()))?; - crate::addons::replay_env_vars(&mut vars, &entry.resources(cx.directories())); + crate::addons::replay_env_vars( + &mut vars, + entry + .artifacts() + .iter() + .flat_map(|artifact| &artifact.steps), + ); } - self.addon_env_vars = vars; - Ok(()) + Ok(vars) } /// Returns the runner recorded when this snapshot was published. diff --git a/src/environment/mod.rs b/src/environment/mod.rs index a1b956a..bf53b83 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -92,14 +92,16 @@ impl Environment { runner: &dyn Runner, root: &Path, cx: &Context, + addons: &crate::Addons, ) -> Result { + let env_vars = config.addon_env_vars(addons)?; prefix::prepare(&config.storage, root, cx).await?; let prefix = root.join("prefix"); let command = config.wrappers.apply(WineBridgeClient::command( runner, &prefix, config.winebridge().path(cx.directories()), - config.effective_env_vars(), + env_vars.iter().chain(config.env_vars.iter()), )); let bridge = match WineBridgeClient::connect_or_spawn(&prefix, command).await { Ok(bridge) => bridge, diff --git a/src/environment/software.rs b/src/environment/software.rs index 7e6f186..253cce2 100644 --- a/src/environment/software.rs +++ b/src/environment/software.rs @@ -107,7 +107,6 @@ pub(crate) async fn reconcile( super::artifacts::prepare_addon(id, candidate, addons, cx, progress, cancellation) .await?; } - candidate.resolve_env_vars(addons, cx)?; super::Environment::prepare(candidate, runner.as_ref(), addons, cx, cancellation).await?; let ids: Vec<_> = candidate.ordered_addons().collect(); if let Storage::Virgo { layers } = &candidate.storage { @@ -137,7 +136,7 @@ pub(crate) async fn reconcile( } let prefix = root.join("prefix"); let winebridge = candidate.winebridge().path(cx.directories()); - let mut env_vars = candidate.addon_env_vars.clone(); + let mut env_vars = previous.addon_env_vars(addons)?; for (id, resources) in removals { let result = uninstall( InstallInputs { @@ -177,6 +176,5 @@ pub(crate) async fn reconcile( super::Environment::stop(candidate, root, cx).await?; result?; } - candidate.resolve_env_vars(addons, cx)?; Ok(()) } From 408462ce3338fc9259b4c06f2198cb0559b36329 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Fri, 11 Sep 2026 18:09:39 +0530 Subject: [PATCH 12/24] refactor(core): materialize Virgo prefixes before execution --- README.md | 45 +++--- src/addons/index/mod.rs | 12 +- src/bottle/edit.rs | 62 +++------ src/bottle/manager.rs | 38 ++---- src/bottle/mod.rs | 2 +- src/bottle/snapshot.rs | 26 ++-- src/bottle/software.rs | 19 +-- src/bottle/state.rs | 2 +- src/environment/config.rs | 14 +- src/environment/error.rs | 12 -- src/environment/history.rs | 7 +- src/environment/mod.rs | 68 ++++------ src/environment/prefix/mod.rs | 83 +++++++++--- src/environment/prefix/standard.rs | 96 +++++++++++++ .../{ => prefix/virgo}/artifacts/cache.rs | 2 +- .../{ => prefix/virgo}/artifacts/mod.rs | 112 ++------------- .../{ => prefix/virgo}/artifacts/software.rs | 50 +------ src/environment/prefix/virgo/mod.rs | 88 ++++++++++-- .../{ => prefix/virgo}/registry.rs | 4 +- src/environment/software.rs | 128 +----------------- 20 files changed, 374 insertions(+), 496 deletions(-) create mode 100644 src/environment/prefix/standard.rs rename src/environment/{ => prefix/virgo}/artifacts/cache.rs (99%) rename src/environment/{ => prefix/virgo}/artifacts/mod.rs (63%) rename src/environment/{ => prefix/virgo}/artifacts/software.rs (74%) rename src/environment/{ => prefix/virgo}/registry.rs (97%) diff --git a/README.md b/README.md index 691967c..daa342f 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Both return `Operation` with the initial Windows process ID. Process inspection and group kill attach to an existing runtime without starting Wine or inspecting FVS mounts. Startup still checks existing Virgo mounts against -saved layers and the private upper when preparing storage. Dropping handles leaves +resolved layers and the private upper when preparing storage. Dropping handles leaves Wine running. Explicit `stop()` waits for wineserver before unmounting, even when WineBridge cannot be reached. Unreachable discovery and mismatched mounts require `stop()` before retrying. @@ -59,18 +59,17 @@ The callback receives a draft of the latest state under the owner lock. Edit Metadata edits work while running. Call `stop()` before changing environment settings, including through `set_component`, `remove_component`, or `install`. Storage is fixed at creation. Standard dependencies may be appended; Virgo -selections may also be removed or reordered. Selections are validated before prefix -work. Virgo prefix mutations checkpoint the complete owner and save configuration -before publication; failures restore both configuration and persistent data. -Settings-only edits save configuration atomically. Standard keeps direct writes. -Metadata-only edits remain available while running. +selections may also be removed or reordered. Standard changes execute installers +before saving and keep direct-write semantics. Virgo creation and edits save +selections without building layers or changing private prefix data. Preparation +errors surface when starting the environment. Virgo builds a clean shared base from the latest catalog runner named `Soda` (case-insensitive, semantic-version ordering). That exact release must already be downloaded. The base manifest pins its release and immutable FVS revision across catalog refreshes. The base is initialized once and reused; there is no rebuild -operation. Startup mounts the owner's saved layers directly; creation and addon -selection changes resolve the exact layer revisions. New bases and adapters use +operation. Stopped preparation builds missing artifacts and resolves the selected +composition before execution. New bases and adapters use `virgo/soda`; existing manifest references and addon caches remain usable. Addon cache misses use pinned Soda and declared prerequisite layers, without @@ -81,10 +80,18 @@ construction is serialized within one core instance. Standard installers continu using their owner's runner. Virgo composition is always Soda base → selected runner adapter → components in -slot order → dependencies in persisted order → private writable upper. Reordering -or removing layers leaves private files, updates, saves, and whiteouts intact. -Cached registry patches are applied in that same order. The owner retains a managed -registry baseline and reapplies private registry changes after rebuilding it. +slot order → dependencies in persisted order → private writable upper. Preparation +resolves each selected addon's immutable layer by UUID and keeps the resulting +stack local to that operation. There is no second persisted list of selections or +layers. The shared base remains pinned and completed UUID caches remain immutable. + +Each preparation reconstructs the managed registry baseline and reapplies private +changes relative to the previous baseline. The owner is checkpointed before that +mutation; failure restores its prior data while keeping the selected configuration +saved for retry. Shared artifact builds happen before the checkpoint. Private +files, updates, saves, and whiteouts keep normal overlay precedence; the upper is +never pruned. A later WineBridge startup failure does not undo successful registry +preparation. Recipe environment variables are derived when starting an environment or running Standard installers; only explicit settings are persisted. Component recipes are @@ -92,14 +99,16 @@ built in, and dependency recipes come from the UUID-pinned local index, not the current catalog. Missing dependency recipe metadata prevents startup. Explicit settings take precedence; execution-owned variables such as `WINEPREFIX`, `WINEARCH`, and `PROTONPATH` are applied last. Snapshots capture owner -metadata, exact layer references, registry baseline, and private prefix data while +metadata, addon selections, registry baseline, and private prefix data while stopped, without WineBridge discovery files. -Bottle configuration uses version 2, with execution settings under `environment` -and resolved FVS layers inside `environment.storage` for Virgo. Version 1 cannot -separate already-mixed explicit and generated settings or recover the old registry -baseline. It is rejected and left untouched; recreate those bottles to use this -format. Completed addon caches remain reusable. +Bottle configuration uses version 1. `environment.storage` selects Standard or +Virgo; layers are derived from selected addons rather than stored in owner state. +Completed addon caches remain reusable. Snapshots stop the runtime +and capture existing configuration, registry baseline, and private data without +preparing Virgo. Pending selections remain pending after restoration and are +prepared at the next launch. Explicit snapshots create a new commit even when +contents are unchanged; `bottles-next:auto-checkpoint` is a reserved message. Operations are lazy. Await them, call `cancel().await`, or spawn them and explicitly detach the task; dropping an operation abandons it. diff --git a/src/addons/index/mod.rs b/src/addons/index/mod.rs index 8d073c8..92d94b0 100644 --- a/src/addons/index/mod.rs +++ b/src/addons/index/mod.rs @@ -20,7 +20,7 @@ use super::{ catalog::{AddonFamily, Catalog}, installer::Artifact, }; -use crate::{Directories, error::Result, runner::Runner}; +use crate::{Directories, error::Result}; mod rebuild; @@ -170,16 +170,6 @@ impl IndexEntry { pub(crate) fn artifact(&self, directories: &Directories) -> Artifact { self.addon.artifact(directories) } - - pub(crate) async fn load_runner( - &self, - directories: &Directories, - umu: Option<&Self>, - ) -> Result> { - self.addon - .load_runner(directories, umu.map(|entry| &entry.addon)) - .await - } } impl IndexEntry { diff --git a/src/bottle/edit.rs b/src/bottle/edit.rs index 78e31af..3620fc1 100644 --- a/src/bottle/edit.rs +++ b/src/bottle/edit.rs @@ -17,8 +17,8 @@ impl Bottle { /// stop first. Storage is fixed; Standard dependencies may only be appended. /// Addon selections must be downloaded. /// Standard mutations write directly; failed recipes can leave partial effects. - /// Virgo prefix mutations checkpoint data and configuration together and restore both - /// on failure. Settings-only edits use atomic configuration saving. + /// Virgo edits save selections atomically; preparation happens before startup. + /// A successful edit does not guarantee that preparation will succeed. pub fn edit( &self, callback: impl FnOnce(&mut BottleState) -> Result<()> + Send + 'static, @@ -54,7 +54,7 @@ impl Bottle { program.validate()?; } draft.environment.validate_requirements()?; - let prefix_changed = crate::environment::validate_edit( + crate::environment::validate_edit( &previous.environment, &draft.environment, &bottle.0.addons, @@ -72,51 +72,21 @@ impl Bottle { return Err(crate::EnvironmentError::MustBeStopped.into()); } crate::environment::Environment::stop(&previous.environment, &root, cx).await?; - } - #[cfg(feature = "fvs")] - let checkpoint = if prefix_changed - && matches!(previous.environment.storage, crate::Storage::Virgo { .. }) - { - Some( - crate::environment::history::capture( - &root, - crate::environment::history::AUTO_CHECKPOINT_MESSAGE.into(), - Stage::Checkpointing, - cx, - &progress, - ) - .await?, + crate::environment::prefix::reconcile( + &previous.environment, + &draft.environment, + &root, + cx, + &bottle.0.addons, + &progress, + &cancellation, ) - } else { - None - }; - let result = async { - if prefix_changed { - crate::environment::reconcile( - &previous.environment, - &mut draft.environment, - &cx.directories().bottle(draft.id), - cx, - &bottle.0.addons, - &progress, - &cancellation, - ) - .await?; - } - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - Self::save_state(&draft, cx).await + .await?; + } + if cancellation.is_cancelled() { + return Err(Error::Cancelled); } - .await; - #[cfg(feature = "fvs")] - let result = if let Some(checkpoint) = checkpoint { - crate::environment::history::recover(result, &root, &checkpoint, cx, &progress) - .await - } else { - result - }; - result?; + Self::save_state(&draft, cx).await?; bottle.publish(draft); Ok(()) }) diff --git a/src/bottle/manager.rs b/src/bottle/manager.rs index c0a4cf7..374574a 100644 --- a/src/bottle/manager.rs +++ b/src/bottle/manager.rs @@ -16,8 +16,6 @@ use tokio::sync::watch; use tokio_stream::wrappers::WatchStream; use uuid::Uuid; -#[cfg(feature = "fvs")] -use crate::environment::prefix::FVS_BLOCK_SIZE; use crate::{ Context, EnvironmentConfig, EnvironmentError, Operation, Progress, Stage, Storage, addons::{Addon, Addons, Requirement, Slot}, @@ -146,7 +144,9 @@ impl BottleManager { /// The newest downloaded WineBridge is selected automatically. A runner /// requiring UMU also receives the newest downloaded UMU release. No addon /// is downloaded implicitly. The runner UUID must identify a downloaded - /// runner component. Standard creation does not require FVS. Failures, and + /// runner component. Standard creation initializes Wine without FVS. Virgo + /// creation only saves selections and creates private storage directories; + /// artifacts and registry data are prepared before startup. Failures, and /// cancellation observed while the operation remains polled, remove the /// partially-created bottle directory on a best-effort basis. Dropping a /// started operation or a cleanup failure can leave a directory that a @@ -201,9 +201,6 @@ impl BottleManager { .into()); } let winebridge = winebridge.unwrap(); // Safe to unwrap since we just checked it above - let loaded_runner = runner_component - .load_runner(cx.directories(), umu.as_deref()) - .await?; let id = Uuid::new_v4(); let bottle_path = cx.directories().bottle(id); @@ -218,48 +215,29 @@ impl BottleManager { if let Some(umu) = umu { components.insert(Slot::Umu, Addon::from(umu.as_ref())); } - let mut config = EnvironmentConfig { + let config = EnvironmentConfig { storage, components, dependencies: Vec::new(), env_vars: Default::default(), wrappers: Default::default(), }; - #[cfg(feature = "fvs")] - if let Storage::Virgo { layers } = &mut config.storage { - layers.clear(); - } - Environment::prepare( - &mut config, - loaded_runner.as_ref(), - &addons, - &cx, - &cancellation, - ) - .await?; prefix::create(&config.storage, &bottle_path).await?; // Initialization may retain live storage on failure; keep it outside the removal path. if matches!(config.storage, Storage::Standard) { + let loaded_runner = config + .runner() + .load_runner(cx.directories(), config.umu()) + .await?; Environment::initialize(loaded_runner.as_ref(), &bottle_path.join("prefix")) .await?; } let result = async { - #[cfg(feature = "fvs")] - if let Storage::Virgo { layers } = &config.storage { - crate::environment::registry::compose(&bottle_path, layers, &[], &cx).await?; - } if cancellation.is_cancelled() { return Err(Error::Cancelled); } let bottle = Bottle::new(id, name, config, cx.clone(), addons.clone()).await?; progress.send_replace(Some(Progress::new(Stage::Configuring))); - #[cfg(feature = "fvs")] - if matches!(&bottle.state()?.environment.storage, Storage::Virgo { .. }) { - cx.fvs() - .await? - .new_repository(&bottle_path, FVS_BLOCK_SIZE) - .await?; - } if cancellation.is_cancelled() { return Err(Error::Cancelled); } diff --git a/src/bottle/mod.rs b/src/bottle/mod.rs index 4a9338e..efc623c 100644 --- a/src/bottle/mod.rs +++ b/src/bottle/mod.rs @@ -14,7 +14,7 @@ //! persisted state until a bottle operation explicitly replaces them. //! //! With the default `fvs` feature, bottles support caller-visible snapshots. -//! Standard history is created on demand; Virgo checkpoints complete configuration and prefix edits. +//! Standard history is created on demand; Virgo checkpoints materialization before execution. //! Long-running mutations return lazy //! [`crate::Operation`] values and serialize with edits, stopping, snapshots, //! and deletion. WineBridge-backed control calls share that coordination. diff --git a/src/bottle/snapshot.rs b/src/bottle/snapshot.rs index 3f90f64..d43027e 100644 --- a/src/bottle/snapshot.rs +++ b/src/bottle/snapshot.rs @@ -13,17 +13,16 @@ impl Bottle { /// /// The operation takes exclusive bottle access and stops the bottle before /// inspecting the complete library-managed bottle directory, including - /// `bottle.toml`. Standard history is initialized on the first snapshot. + /// `bottle.toml` and the existing registry baseline. Pending selections remain + /// pending; this operation does not build artifacts or prepare a new composition. + /// Standard history is initialized on the first snapshot. /// - /// If the tree has not changed, no history entry is created. The returned - /// [`Snapshot`] then has `created == false`, and its state ID, message, and - /// timestamp describe the pre-existing FVS head rather than `message`. - /// The message `bottles-next:auto-checkpoint` is reserved for internal - /// transactions; snapshots using it are hidden by [`snapshots`](Self::snapshots). + /// Explicit snapshots always create a new commit with the requested message, + /// even when the files have not changed. The message + /// `bottles-next:auto-checkpoint` is reserved and rejected. /// - /// Cancellation is observed after stopping and before the FVS commit - /// begins. Once streaming starts, this operation does not check for - /// cancellation again. + /// Cancellation is observed after stopping and before the snapshot commit. + /// Once that stream starts, it is drained without cancellation. /// /// # Errors /// @@ -35,6 +34,13 @@ impl Bottle { let cx = self.0.cx.clone(); let message = message.into(); Operation::new(move |progress, cancellation| async move { + if message == AUTO_CHECKPOINT_MESSAGE { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "snapshot message is reserved for internal checkpoints", + ) + .into()); + } let _control = cancellation .run_until_cancelled(bottle.0.control.lock()) .await @@ -51,6 +57,7 @@ impl Bottle { history::capture( &bottle.bottle_path(), message, + true, Stage::Committing, &cx, &progress, @@ -134,6 +141,7 @@ impl Bottle { let checkpoint = history::capture( &bottle_path, AUTO_CHECKPOINT_MESSAGE.into(), + false, Stage::Checkpointing, &cx, &progress, diff --git a/src/bottle/software.rs b/src/bottle/software.rs index 971c6d5..456c9c9 100644 --- a/src/bottle/software.rs +++ b/src/bottle/software.rs @@ -77,7 +77,7 @@ impl Bottle { } let state = bottle.state()?; let program = resolve(&state)?; - let environment = bottle.attach_or_start(&cancellation).await?; + let environment = bottle.attach_or_start(&progress, &cancellation).await?; environment.launch_program(&program, &cancellation).await }) } @@ -189,9 +189,10 @@ impl Bottle { Environment::stop(&state.environment, &cx.directories().bottle(state.id), cx).await } - // Caller holds the owner lock. Startup uses the saved layer references. + // Caller holds the owner lock. Attachment leaves a running environment untouched. async fn attach_or_start( &self, + progress: &tokio::sync::watch::Sender>, cancellation: &tokio_util::sync::CancellationToken, ) -> Result { let state = self.state()?; @@ -199,20 +200,13 @@ impl Bottle { if let Some(environment) = Environment::try_attach(&root).await? { return Ok(environment); } - let runner = state - .environment - .runner() - .load_runner(self.0.cx.directories(), state.environment.umu()) - .await?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } Environment::start( &state.environment, - runner.as_ref(), &root, &self.0.cx, &self.0.addons, + progress, + cancellation, ) .await } @@ -222,8 +216,9 @@ impl Bottle { F: for<'a> AsyncFnOnce(&'a Environment) -> Result, { let _control = self.0.control.lock().await; + let (progress, _) = tokio::sync::watch::channel(None); let environment = self - .attach_or_start(&tokio_util::sync::CancellationToken::new()) + .attach_or_start(&progress, &tokio_util::sync::CancellationToken::new()) .await?; work(&environment).await } diff --git a/src/bottle/state.rs b/src/bottle/state.rs index d63a895..ba52f17 100644 --- a/src/bottle/state.rs +++ b/src/bottle/state.rs @@ -27,7 +27,7 @@ use crate::{Context, EnvironmentConfig, addons::Addons, error::Result}; /// snapshot was published. Obtain another snapshot to observe later changes. /// Component locations are derived from their slot and version. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Config)] -#[config(version = 2)] +#[config(version = 1)] pub struct BottleState { pub(crate) id: Uuid, pub name: String, diff --git a/src/environment/config.rs b/src/environment/config.rs index 25c6c7d..2188745 100644 --- a/src/environment/config.rs +++ b/src/environment/config.rs @@ -8,7 +8,7 @@ use strum::IntoEnumIterator; use uuid::Uuid; /// Execution settings embedded in a bottle or standalone program's saved state. -/// Storage retains resolved Virgo layers; live runtime resources are never persisted. +/// Virgo layers and recipe variables are derived from these selections on demand. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct EnvironmentConfig { pub storage: Storage, @@ -23,22 +23,18 @@ pub struct EnvironmentConfig { } impl EnvironmentConfig { - #[cfg(feature = "fvs")] - pub(crate) fn ordered_addons(&self) -> impl Iterator + '_ { + /// Prefix-contributing components in fixed slot order. + pub(crate) fn ordered_components(&self) -> impl Iterator> { Slot::iter() .filter(|slot| !slot.is_runtime()) .filter_map(|slot| self.component(slot)) - .map(Addon::id) - .chain(self.dependencies.iter().map(Addon::id)) } /// Derives recipe variables from selections and UUID-pinned local dependency recipes. pub(crate) fn addon_env_vars(&self, addons: &crate::Addons) -> Result { let mut vars = EnvVars::default(); - for slot in Slot::iter().filter(|slot| !slot.is_runtime()) { - if self.component(slot).is_some() { - crate::addons::replay_env_vars(&mut vars, crate::addons::recipe_steps(slot)); - } + for addon in self.ordered_components() { + crate::addons::replay_env_vars(&mut vars, crate::addons::recipe_steps(addon.slot())); } for addon in &self.dependencies { let entry = addons diff --git a/src/environment/error.rs b/src/environment/error.rs index 7b17b5b..8b425bb 100644 --- a/src/environment/error.rs +++ b/src/environment/error.rs @@ -12,18 +12,6 @@ pub enum EnvironmentError { #[source] source: Box, }, - #[cfg(feature = "fvs")] - #[error("no Soda runner release in the current component catalog")] - SodaNotInCatalog, - #[cfg(feature = "fvs")] - #[error("invalid Soda semantic version: {0}")] - InvalidSodaVersion(String), - #[cfg(feature = "fvs")] - #[error("download Soda {version} ({id}) before building the Virgo base or an addon layer")] - SodaNotDownloaded { id: Uuid, version: String }, - #[cfg(feature = "fvs")] - #[error("cyclic addon prerequisites involving {0}")] - CyclicPrerequisites(Uuid), /// Cleanup could not finish; the prefix remains available for explicit shutdown. #[error( diff --git a/src/environment/history.rs b/src/environment/history.rs index 7196326..fdfb046 100644 --- a/src/environment/history.rs +++ b/src/environment/history.rs @@ -1,4 +1,4 @@ -//! Owner history includes saved configuration, resolved layers, and persistent data. +//! Owner history includes selected configuration, the registry baseline, and persistent data. //! Callers hold owner coordination and stop Wine and mounts before using it. use super::prefix::FVS_BLOCK_SIZE; @@ -38,6 +38,7 @@ pub(crate) fn repository(root: &Path) -> Repository { pub(crate) async fn capture( root: &Path, message: String, + allow_empty: bool, stage: Stage, cx: &Context, progress: &watch::Sender>, @@ -49,7 +50,9 @@ pub(crate) async fn capture( if !crate::utils::exists(&root.join(".fvs2")).await? { client.new_repository(root, FVS_BLOCK_SIZE).await?; } - let stream = client.commit_stream(&repository(root), message).await?; + let stream = client + .commit_stream(&repository(root), message, allow_empty) + .await?; finish_commit(stream, |event| { progress.send_replace(Some(Progress::transferring(stage.clone(), event.into()))); }) diff --git a/src/environment/mod.rs b/src/environment/mod.rs index bf53b83..77d50e3 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -1,25 +1,22 @@ //! Shared execution configuration and temporary connections to a running environment. -#[cfg(feature = "fvs")] -pub(crate) mod artifacts; #[cfg(feature = "fvs")] pub(crate) mod history; -#[cfg(feature = "fvs")] -pub(crate) mod registry; mod config; mod error; pub(crate) mod prefix; mod software; -pub(crate) use software::{reconcile, validate_edit}; +pub(crate) use software::validate_edit; use std::path::Path; +use tokio::sync::watch; use tokio_util::sync::CancellationToken; use crate::{ - Context, ProgramSpec, + Context, ProgramSpec, Progress, error::{Error, Result}, proto::{DllOverride, DllOverrideMode, Process}, runner::Runner, @@ -38,38 +35,6 @@ pub(crate) struct Environment { } impl Environment { - /// Derives the immutable stack from configuration while the owner is stopped. - pub(crate) async fn prepare( - config: &mut EnvironmentConfig, - runner: &dyn Runner, - addons: &crate::Addons, - cx: &Context, - cancellation: &CancellationToken, - ) -> Result<()> { - let _ = (runner, addons, cx, cancellation); - #[cfg(feature = "fvs")] - let ids: Vec<_> = config.ordered_addons().collect(); - match &mut config.storage { - Storage::Standard => Ok(()), - #[cfg(feature = "fvs")] - Storage::Virgo { layers } => { - let mut base = artifacts::base_layers( - runner, - &config.components[&crate::Slot::Runner].id().to_string(), - addons, - cx, - cancellation, - ) - .await?; - for id in ids { - base.push(artifacts::cache::layer(id, cx).await?); - } - *layers = base; - Ok(()) - } - } - } - pub(crate) async fn initialize(runner: &dyn Runner, prefix: &Path) -> Result<()> { let initialized = runner.wineboot(prefix, "--init").await; shutdown_wine(runner, prefix).await?; @@ -78,27 +43,44 @@ impl Environment { /// Stops Wine and unmounts storage without requiring a live handle or bridge. pub(crate) async fn stop(config: &EnvironmentConfig, root: &Path, cx: &Context) -> Result<()> { + let prefix = root.join("prefix"); + // A lazily created Virgo owner has no runtime mountpoint yet. + if !crate::utils::exists(&prefix).await? { + return Ok(()); + } let runner = config .runner() .load_runner(cx.directories(), config.umu()) .await?; - shutdown_wine(runner.as_ref(), &root.join("prefix")).await?; + shutdown_wine(runner.as_ref(), &prefix).await?; prefix::stop(&config.storage, root, cx).await } - /// Starts from references already resolved and published by the owner. + /// Materializes pending selections, then mounts and starts the selected runtime. pub(crate) async fn start( config: &EnvironmentConfig, - runner: &dyn Runner, root: &Path, cx: &Context, addons: &crate::Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, ) -> Result { let env_vars = config.addon_env_vars(addons)?; - prefix::prepare(&config.storage, root, cx).await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + Self::stop(config, root, cx).await?; + let runner = config + .runner() + .load_runner(cx.directories(), config.umu()) + .await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + prefix::prepare(config, root, cx, addons, progress, cancellation).await?; let prefix = root.join("prefix"); let command = config.wrappers.apply(WineBridgeClient::command( - runner, + runner.as_ref(), &prefix, config.winebridge().path(cx.directories()), env_vars.iter().chain(config.env_vars.iter()), diff --git a/src/environment/prefix/mod.rs b/src/environment/prefix/mod.rs index 7cefe95..cd7b331 100644 --- a/src/environment/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -1,9 +1,10 @@ -//! Prefix storage backends. +//! Prefix preparation and storage backends. //! //! Standard storage mutates a conventional prefix directly; Virgo stores an -//! ordered FVS layer stack with a private writable upper directory. Virgo addon -//! changes use rollback checkpoints; Standard uses FVS only for explicit snapshots. +//! ordered FVS layer stack with a private writable upper directory. Virgo +//! materialization uses rollback checkpoints; Standard uses FVS only for explicit snapshots. +mod standard; #[cfg(feature = "fvs")] mod virgo; @@ -12,11 +13,11 @@ use std::path::Path; #[cfg(feature = "fvs")] pub use virgo::VirgoError; -#[cfg(feature = "fvs")] -use fvs_rs::Layer; use serde::{Deserialize, Serialize}; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; -use crate::{Context, error::Result}; +use crate::{Addons, Context, EnvironmentConfig, Progress, error::Result}; #[cfg(feature = "fvs")] pub(crate) const FVS_BLOCK_SIZE: u32 = 1024 * 1024; @@ -32,11 +33,7 @@ pub enum Storage { /// /// Virgo is experimental and requires the configured FVS service. #[cfg(feature = "fvs")] - Virgo { - /// Exact immutable revisions derived from the environment selections. - #[serde(default)] - layers: Vec, - }, + Virgo, } /// Creates storage at an explicit owner location. @@ -44,17 +41,69 @@ pub(crate) async fn create(storage: &Storage, root: &Path) -> Result<()> { let directory = match storage { Storage::Standard => "prefix", #[cfg(feature = "fvs")] - Storage::Virgo { .. } => "upper", + Storage::Virgo => "upper", }; Ok(async_fs::create_dir_all(root.join(directory)).await?) } -pub(crate) async fn prepare(storage: &Storage, root: &Path, context: &Context) -> Result<()> { - let _ = (root, context); - match storage { +/// Checks backend restrictions before the owner stops or publishes an edit. +pub(crate) fn validate_edit( + previous: &EnvironmentConfig, + candidate: &EnvironmentConfig, +) -> Result<()> { + if matches!(candidate.storage, Storage::Standard) + && !candidate.dependencies.starts_with(&previous.dependencies) + { + return Err(crate::EnvironmentError::InvalidEdit( + "installed dependencies cannot be removed, replaced or reordered", + ) + .into()); + } + Ok(()) +} + +/// Applies edited selections to a stopped prefix; Virgo defers work until preparation. +pub(crate) async fn reconcile( + previous: &EnvironmentConfig, + candidate: &EnvironmentConfig, + root: &Path, + cx: &Context, + addons: &Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, +) -> Result<()> { + match candidate.storage { + Storage::Standard => { + standard::reconcile( + previous, + candidate, + root, + cx, + addons, + progress, + cancellation, + ) + .await + } + #[cfg(feature = "fvs")] + Storage::Virgo => Ok(()), + } +} + +/// Prepares a stopped owner's prefix for execution. +pub(crate) async fn prepare( + config: &EnvironmentConfig, + root: &Path, + cx: &Context, + addons: &Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, +) -> Result<()> { + let _ = (root, cx, addons, progress, cancellation); + match config.storage { Storage::Standard => Ok(()), #[cfg(feature = "fvs")] - Storage::Virgo { layers } => virgo::prepare(root, layers, context).await, + Storage::Virgo => virgo::prepare(config, root, cx, addons, progress, cancellation).await, } } @@ -63,6 +112,6 @@ pub(crate) async fn stop(storage: &Storage, root: &Path, context: &Context) -> R match storage { Storage::Standard => Ok(()), #[cfg(feature = "fvs")] - Storage::Virgo { .. } => virgo::stop(root, context).await, + Storage::Virgo => virgo::stop(root, context).await, } } diff --git a/src/environment/prefix/standard.rs b/src/environment/prefix/standard.rs new file mode 100644 index 0000000..f961c18 --- /dev/null +++ b/src/environment/prefix/standard.rs @@ -0,0 +1,96 @@ +//! Direct installation into a conventional mutable prefix. + +use std::path::Path; + +use strum::IntoEnumIterator; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; + +use crate::{ + AddonError, Addons, Context, EnvironmentConfig, Progress, Slot, Stage, + addons::{InstallInputs, execute, uninstall}, + environment::Environment, + error::Result, +}; + +/// Apply validated Standard selections directly to the stopped owner's prefix. +pub(super) async fn reconcile( + previous: &EnvironmentConfig, + candidate: &EnvironmentConfig, + root: &Path, + cx: &Context, + addons: &Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, +) -> Result<()> { + let mut removals = Vec::new(); + let mut installations = Vec::new(); + for slot in Slot::iter().filter(|slot| !slot.is_runtime()) { + let old = previous.component(slot); + let new = candidate.component(slot); + if old == new { + continue; + } + if let Some(new) = new { + installations.push(vec![new.artifact(cx.directories())]); + } else if let Some(old) = old { + removals.push((old.id(), vec![old.artifact(cx.directories())])); + } + } + for new in &candidate.dependencies[previous.dependencies.len()..] { + let downloaded = addons + .dependency(new.id()) + .ok_or(AddonError::NotFound(new.id()))?; + installations.push(downloaded.resources(cx.directories())); + } + if removals.is_empty() && installations.is_empty() { + return Ok(()); + } + let runner = candidate + .runner() + .load_runner(cx.directories(), candidate.umu()) + .await?; + let prefix = root.join("prefix"); + let winebridge = candidate.winebridge().path(cx.directories()); + let mut env_vars = previous.addon_env_vars(addons)?; + for (id, resources) in removals { + let result = uninstall( + InstallInputs { + prefix: &prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars: &mut env_vars, + explicit_env_vars: &candidate.env_vars, + }, + &resources, + id, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Removing))); + }, + ) + .await; + Environment::stop(candidate, root, cx).await?; + result?; + } + for resources in installations { + let result = execute( + InstallInputs { + prefix: &prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars: &mut env_vars, + explicit_env_vars: &candidate.env_vars, + }, + &resources, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Configuring))); + }, + ) + .await; + Environment::stop(candidate, root, cx).await?; + result?; + } + Ok(()) +} diff --git a/src/environment/artifacts/cache.rs b/src/environment/prefix/virgo/artifacts/cache.rs similarity index 99% rename from src/environment/artifacts/cache.rs rename to src/environment/prefix/virgo/artifacts/cache.rs index ff65685..b5fe256 100644 --- a/src/environment/artifacts/cache.rs +++ b/src/environment/prefix/virgo/artifacts/cache.rs @@ -9,7 +9,7 @@ use std::{ path::{Path, PathBuf}, }; -use crate::environment::registry::{registry_files, write_forward}; +use super::super::registry::{registry_files, write_forward}; use fvs_rs::{Layer, UnmountMode}; use regdiff_rs::prelude::apply_files; use uuid::Uuid; diff --git a/src/environment/artifacts/mod.rs b/src/environment/prefix/virgo/artifacts/mod.rs similarity index 63% rename from src/environment/artifacts/mod.rs rename to src/environment/prefix/virgo/artifacts/mod.rs index e18affa..fd40644 100644 --- a/src/environment/artifacts/mod.rs +++ b/src/environment/prefix/virgo/artifacts/mod.rs @@ -1,12 +1,13 @@ //! Shared immutable bases, runner adapters, and UUID-only addon caches. -pub(crate) mod cache; +pub(super) mod cache; mod software; pub(crate) use software::prepare_addon; -use super::prefix::{FVS_BLOCK_SIZE, VirgoError}; +use super::VirgoError; +use crate::environment::prefix::FVS_BLOCK_SIZE; use crate::{ - Addon, Addons, CatalogEntry, Component, Context, EnvironmentError, IndexEntry, Slot, + Addon, Addons, CatalogEntry, Component, Context, IndexEntry, Slot, error::{Error, Result}, runner::Runner, }; @@ -36,7 +37,7 @@ fn latest_soda(entries: &[CatalogEntry]) -> Result<&CatalogEntry current) @@ -46,7 +47,7 @@ fn latest_soda(entries: &[CatalogEntry]) -> Result<&CatalogEntry CatalogEntry { - serde_json::from_value(json!({ - "id": Uuid::new_v4(), "name": name, "version": version, "slot": "runner", - "artifacts": [{"url": "https://example.invalid/soda.tar", "file_name": "soda.tar", - "checksum": {"algorithm": "sha256", "value": "unused"}}] - })) - .unwrap() - } - - #[tokio::test] - async fn soda_selection_pinning_and_uuid_reuse_without_services() { - let entries = vec![ - release("SODA", "2.9.0"), - release("Soda", "2.10.0"), - release("Wine", "99.0.0"), - ]; - assert_eq!(latest_soda(&entries).unwrap().id(), entries[1].id()); - assert!(matches!( - latest_soda(&[release("Soda", "invalid")]), - Err(Error::Environment(EnvironmentError::InvalidSodaVersion(_))) - )); - let root = std::env::temp_dir().join(format!("soda-test-{}", Uuid::new_v4())); - let cx = Context::for_test(crate::Directories::from_path(&root).unwrap(), None).unwrap(); - async_fs::write( - cx.directories().components().join("catalog.json"), - serde_json::to_vec(&json!({"schema_version": 1, "entries": entries})).unwrap(), - ) - .await - .unwrap(); - let addons = Addons::load(cx.clone(), None, None).await.unwrap(); - let cancellation = CancellationToken::new(); - assert!( - matches!(ensure_base(&addons, &cx, &cancellation).await, Err(Error::Environment(EnvironmentError::SodaNotDownloaded { id, .. })) if id == entries[1].id()) - ); - - let soda = serde_json::from_value( - json!({"id": entries[0].id(), "name": "Soda", "version": "2.9.0", "slot": "runner"}), - ) - .unwrap(); - let layer = Layer::new( - &Repository { - repository_path: root.join("old-base").display().to_string(), - block_size: FVS_BLOCK_SIZE, - }, - Some(&fvs_rs::Commit { - state_id: "pinned".into(), - ..Default::default() - }), - ); - next_config::save( - manifest(&cx), - &Base { - soda, - layer: layer.clone(), - }, - ) - .await - .unwrap(); - let pinned = ensure_base(&addons, &cx, &cancellation).await.unwrap(); - assert_eq!(pinned.soda.id(), entries[0].id()); - assert_eq!(pinned.layer, layer); - let id = Uuid::new_v4(); - async_fs::create_dir_all( - cx.directories() - .data_dir() - .join("virgo/layers") - .join(id.to_string()) - .join(".fvs2"), - ) - .await - .unwrap(); - let mut config = crate::EnvironmentConfig { - storage: crate::Storage::Virgo { layers: vec![] }, - components: Default::default(), - dependencies: vec![], - env_vars: Default::default(), - wrappers: Default::default(), - }; - let (progress, _) = tokio::sync::watch::channel(None); - prepare_addon(id, &config, &addons, &cx, &progress, &cancellation) - .await - .unwrap(); - config.components.insert(Slot::Runner, pinned.soda); - prepare_addon(id, &config, &addons, &cx, &progress, &cancellation) - .await - .unwrap(); - drop(addons); - drop(cx); - async_fs::remove_dir_all(root).await.unwrap(); - } -} diff --git a/src/environment/artifacts/software.rs b/src/environment/prefix/virgo/artifacts/software.rs similarity index 74% rename from src/environment/artifacts/software.rs rename to src/environment/prefix/virgo/artifacts/software.rs index b79a2d8..e62a5da 100644 --- a/src/environment/artifacts/software.rs +++ b/src/environment/prefix/virgo/artifacts/software.rs @@ -5,6 +5,7 @@ use tokio::sync::watch; use tokio_util::sync::CancellationToken; use uuid::Uuid; +use super::super::VirgoError; use super::{cache, downloaded_soda, ensure_base}; use crate::{ AddonError, Addons, Context, EnvVars, EnvironmentError, Progress, Requirement, Slot, Stage, @@ -61,7 +62,7 @@ fn visit( return Ok(()); } if visiting.contains(&id) { - return Err(EnvironmentError::CyclicPrerequisites(id).into()); + return Err(VirgoError::CyclicPrerequisites(id).into()); } visiting.push(id); for prerequisite in prerequisites(id, config)? { @@ -98,7 +99,7 @@ pub(crate) async fn prepare_addon( } let base = ensure_base(addons, cx, cancellation).await?; let soda = downloaded_soda(base.soda.id(), base.soda.version(), addons)?; - let runner = soda.load_runner(cx.directories(), None).await?; + let runner = soda.addon().load_runner(cx.directories(), None).await?; let winebridge = addons .latest_component(Slot::WineBridge) .ok_or(EnvironmentError::ComponentNotInstalled(Slot::WineBridge))? @@ -155,48 +156,3 @@ pub(crate) async fn prepare_addon( } Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn prerequisite_order_excludes_unrelated_addons_and_rejects_cycles() { - let ids = [ - Uuid::new_v4(), - Uuid::new_v4(), - Uuid::new_v4(), - Uuid::new_v4(), - ]; - let dependency = |id: Uuid, requirements: Vec| { - serde_json::from_value(json!({ - "id": id, "name": id.to_string(), "version": "1.0.0", "requirements": requirements - })) - .unwrap() - }; - let mut config = EnvironmentConfig { - storage: crate::Storage::Virgo { layers: vec![] }, - components: Default::default(), - dependencies: vec![ - dependency( - ids[0], - vec![Requirement::Id(ids[1]), Requirement::Id(ids[2])], - ), - dependency(ids[1], vec![Requirement::Id(ids[2])]), - dependency(ids[2], vec![]), - dependency(ids[3], vec![]), - ], - env_vars: Default::default(), - wrappers: Default::default(), - }; - let mut order = Vec::new(); - visit(ids[0], &config, &mut Vec::new(), &mut order).unwrap(); - assert_eq!(order, [ids[2], ids[1], ids[0]]); - config.dependencies[2] = dependency(ids[2], vec![Requirement::Id(ids[0])]); - assert!(matches!( - visit(ids[0], &config, &mut Vec::new(), &mut Vec::new()), - Err(Error::Environment(EnvironmentError::CyclicPrerequisites(_))) - )); - } -} diff --git a/src/environment/prefix/virgo/mod.rs b/src/environment/prefix/virgo/mod.rs index 7cd51a5..ed96910 100644 --- a/src/environment/prefix/virgo/mod.rs +++ b/src/environment/prefix/virgo/mod.rs @@ -2,19 +2,38 @@ //! //! A mounted prefix combines a shared base, a runner-specific adapter, cached //! addon layers, and the owner's writable `upper` directory. Layer order is -//! persisted by the owner and must be changed only while the owner is -//! stopped. +//! derived from selected addons when preparing a stopped environment. + +mod artifacts; +mod registry; use std::path::{Path, PathBuf}; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + use futures_lite::StreamExt; use fvs_rs::{Layer, UnmountMode}; -use crate::{Context, error::Result}; +use crate::{ + Context, Progress, Stage, + environment::{EnvironmentConfig, history}, + error::{Error, Result}, +}; /// Virgo-specific failures carried by [`crate::error::Error::Virgo`]. #[derive(Debug, thiserror::Error)] pub enum VirgoError { + #[error("no Soda runner release in the current component catalog")] + SodaNotInCatalog, + #[error("invalid Soda semantic version: {0}")] + InvalidSodaVersion(String), + #[error("download Soda {version} ({id}) before building the Virgo base or an addon layer")] + SodaNotDownloaded { id: Uuid, version: String }, + #[error("cyclic addon prerequisites involving {0}")] + CyclicPrerequisites(Uuid), + /// A required FVS commit is missing from a repository. #[error("FVS repository {repository} has no commit {state}")] MissingCommit { @@ -27,7 +46,7 @@ pub enum VirgoError { #[error("mountpoint is not empty: {0}")] DirtyMountpoint(PathBuf), #[error( - "mounted layers or writable upper differ from saved configuration at {0}; call stop() and retry" + "mounted layers or writable upper differ from the selected composition at {0}; call stop() and retry" )] MountMismatch(PathBuf), /// A cached layer required to construct the prefix is missing. @@ -38,14 +57,65 @@ pub enum VirgoError { Registry(String), } -pub(super) async fn prepare(root: &Path, layers: &[Layer], context: &Context) -> Result<()> { - if !existing_mount(root, layers, context).await? { +/// Builds the selected Virgo composition while the owner is coordinated and stopped. +pub(super) async fn prepare( + config: &EnvironmentConfig, + root: &Path, + cx: &Context, + addons: &crate::Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, +) -> Result<()> { + let ids: Vec<_> = config + .ordered_components() + .map(crate::Addon::id) + .chain(config.dependencies.iter().map(crate::Addon::id)) + .collect(); + let runner = config + .runner() + .load_runner(cx.directories(), config.umu()) + .await?; + for id in &ids { + artifacts::prepare_addon(*id, config, addons, cx, progress, cancellation).await?; + } + let mut layers = artifacts::base_layers( + runner.as_ref(), + &config.runner().id().to_string(), + addons, + cx, + cancellation, + ) + .await?; + for id in &ids { + layers.push(artifacts::cache::layer(*id, cx).await?); + } + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let checkpoint = history::capture( + root, + history::AUTO_CHECKPOINT_MESSAGE.into(), + false, + Stage::Checkpointing, + cx, + progress, + ) + .await?; + let result = async { + registry::compose(root, &layers, &ids, cx).await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + Ok(()) + } + .await; + history::recover(result, root, &checkpoint, cx, progress).await?; + if !existing_mount(root, &layers, cx).await? { let prefix = root.join("prefix"); ensure_empty_dir(&prefix).await?; - context - .fvs() + cx.fvs() .await? - .mount(&prefix, layers.to_vec(), Some(root.join("upper"))) + .mount(&prefix, layers, Some(root.join("upper"))) .await?; } Ok(()) diff --git a/src/environment/registry.rs b/src/environment/prefix/virgo/registry.rs similarity index 97% rename from src/environment/registry.rs rename to src/environment/prefix/virgo/registry.rs index 31b402f..dc8a56f 100644 --- a/src/environment/registry.rs +++ b/src/environment/prefix/virgo/registry.rs @@ -1,6 +1,6 @@ //! Compose the managed registry baseline, then replay private changes over it. -use super::{artifacts::cache, prefix::VirgoError}; +use super::{VirgoError, artifacts::cache}; use crate::{ Context, error::{Error, Result}, @@ -90,7 +90,7 @@ pub(crate) async fn compose( client .unmount(&mount, UnmountMode::Normal) .await - .map_err(|source| super::EnvironmentError::Cleanup { + .map_err(|source| crate::EnvironmentError::Cleanup { prefix, source: Box::new(source.into()), })?; diff --git a/src/environment/software.rs b/src/environment/software.rs index 253cce2..b654b1c 100644 --- a/src/environment/software.rs +++ b/src/environment/software.rs @@ -1,42 +1,20 @@ -//! Reconcile edited execution settings with persistent prefix data. - -use std::path::Path; +//! Validate edited execution selections before lifecycle or prefix work. use strum::IntoEnumIterator; -use tokio::sync::watch; -use tokio_util::sync::CancellationToken; -#[cfg(feature = "fvs")] -use super::Storage; use super::{EnvironmentConfig, EnvironmentError}; -use crate::{ - Addon, AddonError, Addons, Context, Progress, Slot, Stage, - addons::{InstallInputs, execute, uninstall}, - error::Result, -}; +use crate::{Addon, AddonError, Addons, Slot, error::Result}; -/// Validate selections before lifecycle or checkpoint work; report whether reconciliation is needed. +/// Validate selections before lifecycle or prefix work. pub(crate) fn validate_edit( previous: &EnvironmentConfig, candidate: &EnvironmentConfig, addons: &Addons, -) -> Result { +) -> Result<()> { if candidate.storage != previous.storage { - return Err(EnvironmentError::InvalidEdit( - "storage strategy and resolved layers are managed by the environment", - ) - .into()); + return Err(EnvironmentError::InvalidEdit("storage strategy is fixed at creation").into()); } - if matches!(candidate.storage, super::Storage::Standard) - && !candidate.dependencies.starts_with(&previous.dependencies) - { - return Err(EnvironmentError::InvalidEdit( - "installed dependencies cannot be removed, replaced or reordered", - ) - .into()); - } - - let mut prefix_changed = candidate.dependencies != previous.dependencies; + super::prefix::validate_edit(previous, candidate)?; for slot in Slot::iter() { let old = previous.component(slot); @@ -44,7 +22,6 @@ pub(crate) fn validate_edit( if old == new { continue; } - prefix_changed |= !slot.is_runtime() || slot == Slot::Runner; if let Some(new) = new { let downloaded = addons .component(new.id()) @@ -83,98 +60,5 @@ pub(crate) fn validate_edit( } } - Ok(prefix_changed) -} - -/// Apply a validated edit that needs prefix work. The owner is stopped and -/// checkpoints Virgo before calling; configuration is saved before publication. -pub(crate) async fn reconcile( - previous: &EnvironmentConfig, - candidate: &mut EnvironmentConfig, - root: &Path, - cx: &Context, - addons: &Addons, - progress: &watch::Sender>, - cancellation: &CancellationToken, -) -> Result<()> { - let runner = candidate - .runner() - .load_runner(cx.directories(), candidate.umu()) - .await?; - #[cfg(feature = "fvs")] - if matches!(candidate.storage, Storage::Virgo { .. }) { - for id in candidate.ordered_addons() { - super::artifacts::prepare_addon(id, candidate, addons, cx, progress, cancellation) - .await?; - } - super::Environment::prepare(candidate, runner.as_ref(), addons, cx, cancellation).await?; - let ids: Vec<_> = candidate.ordered_addons().collect(); - if let Storage::Virgo { layers } = &candidate.storage { - super::registry::compose(root, layers, &ids, cx).await?; - } - return Ok(()); - } - let mut removals = Vec::new(); - let mut installations = Vec::new(); - for slot in Slot::iter().filter(|slot| !slot.is_runtime()) { - let old = previous.component(slot); - let new = candidate.component(slot); - if old == new { - continue; - } - if let Some(new) = new { - installations.push(vec![new.artifact(cx.directories())]); - } else if let Some(old) = old { - removals.push((old.id(), vec![old.artifact(cx.directories())])); - } - } - for new in &candidate.dependencies[previous.dependencies.len()..] { - let downloaded = addons - .dependency(new.id()) - .ok_or(AddonError::NotFound(new.id()))?; - installations.push(downloaded.resources(cx.directories())); - } - let prefix = root.join("prefix"); - let winebridge = candidate.winebridge().path(cx.directories()); - let mut env_vars = previous.addon_env_vars(addons)?; - for (id, resources) in removals { - let result = uninstall( - InstallInputs { - prefix: &prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars: &mut env_vars, - explicit_env_vars: &candidate.env_vars, - }, - &resources, - id, - cancellation, - |_| { - progress.send_replace(Some(Progress::new(Stage::Removing))); - }, - ) - .await; - super::Environment::stop(candidate, root, cx).await?; - result?; - } - for resources in installations { - let result = execute( - InstallInputs { - prefix: &prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars: &mut env_vars, - explicit_env_vars: &candidate.env_vars, - }, - &resources, - cancellation, - |_| { - progress.send_replace(Some(Progress::new(Stage::Configuring))); - }, - ) - .await; - super::Environment::stop(candidate, root, cx).await?; - result?; - } Ok(()) } From 8bcf816ac735605e347eb9e512563c51f9874a8b Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Fri, 11 Sep 2026 18:13:13 +0530 Subject: [PATCH 13/24] fix(core): build addon layers against Soda alone --- README.md | 6 +- .../prefix/virgo/artifacts/cache.rs | 10 +- .../prefix/virgo/artifacts/software.rs | 147 ++++-------------- src/environment/prefix/virgo/mod.rs | 4 +- 4 files changed, 39 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index daa342f..9455f18 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,10 @@ operation. Stopped preparation builds missing artifacts and resolves the selecte composition before execution. New bases and adapters use `virgo/soda`; existing manifest references and addon caches remain usable. -Addon cache misses use pinned Soda and declared prerequisite layers, without -owner settings, wrappers, or private writable data. UUID remains the sole cache +Every addon recipe must install against pinned Soda alone. Cache construction +uses no layers, registry patches, or environment contributions from other addons, +and no owner settings, wrappers, or private writable data. Requirements are validated against +the final environment selections. UUID remains the sole cache identity: completed caches survive runner and settings changes. Runner adapters are built using the selected runner over the pinned base. Shared construction is serialized within one core instance. Standard installers continue diff --git a/src/environment/prefix/virgo/artifacts/cache.rs b/src/environment/prefix/virgo/artifacts/cache.rs index b5fe256..e3956e9 100644 --- a/src/environment/prefix/virgo/artifacts/cache.rs +++ b/src/environment/prefix/virgo/artifacts/cache.rs @@ -32,7 +32,7 @@ pub(crate) async fn exists(id: Uuid, context: &Context) -> Result { /// Builds and publishes the cached filesystem layer and registry patches. /// -/// Installation runs in a unique staging mount over the preceding layers. The +/// Installation runs in a unique staging mount over the pinned Soda base. The /// registry is diffed separately, unchanged filesystem entries are pruned by /// FVS, and the registry hives are removed before the upper directory is /// committed as a reusable layer. @@ -42,9 +42,8 @@ pub(crate) async fn exists(id: Uuid, context: &Context) -> Result { /// failure may therefore leave only one destination present. Staging cleanup is /// best-effort. pub(crate) async fn install( - layers: Vec, + base: Layer, item_id: Uuid, - prerequisites: &[Uuid], runner: &dyn Runner, execute: F, context: &Context, @@ -81,11 +80,8 @@ where } let client = context.fvs().await?; - let mount = client.mount(&prefix, layers, Some(&upper)).await?; + let mount = client.mount(&prefix, vec![base], Some(&upper)).await?; let installed = async { - for id in prerequisites { - apply_registry(&prefix, *id, context).await?; - } for (file, _) in registry_files() { async_fs::copy(prefix.join(file), before.join(file)).await?; } diff --git a/src/environment/prefix/virgo/artifacts/software.rs b/src/environment/prefix/virgo/artifacts/software.rs index e62a5da..5203954 100644 --- a/src/environment/prefix/virgo/artifacts/software.rs +++ b/src/environment/prefix/virgo/artifacts/software.rs @@ -1,78 +1,16 @@ -//! Builds only declared prerequisites over Soda, without owner settings or upper data. +//! Builds each addon against pinned Soda alone, without owner settings or private data. -use strum::IntoEnumIterator; use tokio::sync::watch; use tokio_util::sync::CancellationToken; use uuid::Uuid; -use super::super::VirgoError; use super::{cache, downloaded_soda, ensure_base}; use crate::{ - AddonError, Addons, Context, EnvVars, EnvironmentError, Progress, Requirement, Slot, Stage, - addons::{Artifact, InstallInputs, execute, replay_env_vars}, - environment::EnvironmentConfig, + AddonError, Addons, Context, EnvVars, EnvironmentError, Progress, Slot, Stage, + addons::{Artifact, InstallInputs, execute}, error::{Error, Result}, }; -fn requirements(id: Uuid, config: &EnvironmentConfig) -> Result<&[Requirement]> { - if let Some(addon) = config.components.values().find(|addon| addon.id() == id) { - return Ok(addon.requirements()); - } - Ok(config - .dependency(id) - .ok_or(AddonError::NotFound(id))? - .requirements()) -} - -/// Runtime requirements supply tools, not layers. Soda always supplies Wine. -fn prerequisites(id: Uuid, config: &EnvironmentConfig) -> Result> { - let mut ids = Vec::new(); - for requirement in requirements(id, config)? { - if let Some(addon) = Slot::iter() - .filter_map(|slot| config.component(slot)) - .find(|addon| addon.satisfies(requirement)) - { - if !addon.slot().is_runtime() { - ids.push(addon.id()); - } - } else if let Some(addon) = config - .dependencies - .iter() - .find(|addon| addon.satisfies(requirement)) - { - ids.push(addon.id()); - } else { - return Err(EnvironmentError::RequiresAddon { - required_by: Some(id), - requirements: vec![requirement.clone()], - } - .into()); - } - } - Ok(ids) -} - -fn visit( - id: Uuid, - config: &EnvironmentConfig, - visiting: &mut Vec, - ordered: &mut Vec, -) -> Result<()> { - if ordered.contains(&id) { - return Ok(()); - } - if visiting.contains(&id) { - return Err(VirgoError::CyclicPrerequisites(id).into()); - } - visiting.push(id); - for prerequisite in prerequisites(id, config)? { - visit(prerequisite, config, visiting, ordered)?; - } - visiting.pop(); - ordered.push(id); - Ok(()) -} - fn resources(id: Uuid, addons: &Addons, cx: &Context) -> Result> { if let Some(component) = addons.component(id) { return Ok(vec![component.artifact(cx.directories())]); @@ -83,7 +21,6 @@ fn resources(id: Uuid, addons: &Addons, cx: &Context) -> Result> { pub(crate) async fn prepare_addon( id: Uuid, - config: &EnvironmentConfig, addons: &Addons, cx: &Context, progress: &watch::Sender>, @@ -104,55 +41,33 @@ pub(crate) async fn prepare_addon( .latest_component(Slot::WineBridge) .ok_or(EnvironmentError::ComponentNotInstalled(Slot::WineBridge))? .path(cx.directories()); - let mut order = Vec::new(); - visit(id, config, &mut Vec::new(), &mut order)?; - for id in order { - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - if cache::exists(id, cx).await? { - continue; - } - let mut required = Vec::new(); - visit(id, config, &mut Vec::new(), &mut required)?; - required.pop(); - let mut layers = vec![base.layer.clone()]; - let mut env_vars = EnvVars::default(); - for prerequisite in &required { - layers.push(cache::layer(*prerequisite, cx).await?); - replay_env_vars( - &mut env_vars, - resources(*prerequisite, addons, cx)? - .iter() - .flat_map(|resource| &resource.steps), - ); - } - let resources = resources(id, addons, cx)?; - cache::install( - layers, - id, - &required, - runner.as_ref(), - async |prefix| { - execute( - InstallInputs { - prefix, - runner: runner.as_ref(), - winebridge: &winebridge, - env_vars: &mut env_vars, - explicit_env_vars: &EnvVars::default(), - }, - &resources, - cancellation, - |_| { - progress.send_replace(Some(Progress::new(Stage::Configuring))); - }, - ) - .await - }, - cx, - ) - .await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); } - Ok(()) + let mut env_vars = EnvVars::default(); + let resources = resources(id, addons, cx)?; + cache::install( + base.layer, + id, + runner.as_ref(), + async |prefix| { + execute( + InstallInputs { + prefix, + runner: runner.as_ref(), + winebridge: &winebridge, + env_vars: &mut env_vars, + explicit_env_vars: &EnvVars::default(), + }, + &resources, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Configuring))); + }, + ) + .await + }, + cx, + ) + .await } diff --git a/src/environment/prefix/virgo/mod.rs b/src/environment/prefix/virgo/mod.rs index ed96910..cd59db5 100644 --- a/src/environment/prefix/virgo/mod.rs +++ b/src/environment/prefix/virgo/mod.rs @@ -31,8 +31,6 @@ pub enum VirgoError { InvalidSodaVersion(String), #[error("download Soda {version} ({id}) before building the Virgo base or an addon layer")] SodaNotDownloaded { id: Uuid, version: String }, - #[error("cyclic addon prerequisites involving {0}")] - CyclicPrerequisites(Uuid), /// A required FVS commit is missing from a repository. #[error("FVS repository {repository} has no commit {state}")] @@ -76,7 +74,7 @@ pub(super) async fn prepare( .load_runner(cx.directories(), config.umu()) .await?; for id in &ids { - artifacts::prepare_addon(*id, config, addons, cx, progress, cancellation).await?; + artifacts::prepare_addon(*id, addons, cx, progress, cancellation).await?; } let mut layers = artifacts::base_layers( runner.as_ref(), From 22c2b82f929c6e993086652fe62c6b0eb20af58f Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Fri, 11 Sep 2026 20:34:25 +0530 Subject: [PATCH 14/24] refactor(core): centralize environment lifecycle workflows --- README.md | 27 ++- src/bottle/edit.rs | 36 +-- src/bottle/manager.rs | 77 +----- src/bottle/snapshot.rs | 6 +- src/bottle/software.rs | 196 ++++++--------- src/bottle/tests.rs | 7 +- src/environment/config.rs | 154 +++++++++++- src/environment/mod.rs | 225 ++++++++++-------- src/environment/prefix/mod.rs | 178 +++++++------- src/environment/prefix/standard.rs | 43 +++- .../prefix/virgo/artifacts/cache.rs | 4 +- src/environment/prefix/virgo/artifacts/mod.rs | 4 +- src/environment/prefix/virgo/mod.rs | 103 ++++---- src/environment/runtime.rs | 32 +++ src/environment/software.rs | 64 ----- src/error.rs | 2 +- src/lib.rs | 2 +- src/winebridge.rs | 23 +- 18 files changed, 631 insertions(+), 552 deletions(-) create mode 100644 src/environment/runtime.rs delete mode 100644 src/environment/software.rs diff --git a/README.md b/README.md index 9455f18..aa2b5c0 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,25 @@ a temporary environment connection; registered programs use the bottle's setting unregistered executable; `Bottle::launch_program(uuid)` runs a registration. Both return `Operation` with the initial Windows process ID. +DLL override queries and changes also return `Operation`, exposing preparation +progress and cooperative cancellation. Existing `.await` calls continue to work. +An `Environment` represents a running execution environment and holds a required +WineBridge connection. `attach_or_start` constructs it directly from the owner's +configuration and location, including after an application restart. The handle retains +neither configuration nor services; dropping it leaves Wine running. Launch and DLL +operations use Bottle's `with_environment` helper, which holds the owner lock, resolves +registration inputs before startup, and forwards progress and cancellation. + +Initialization, configuration edits, and shutdown are associated functions on +`Environment` that take the owner's inputs and return completion, without requiring +a running handle. +The owner retains configuration, persistence, publication, and coordination. +`PrefixBackend` owns how a runnable prefix is created and maintained: initialization, supported edits, software +materialization, composition, and storage release. Standard and Virgo implementations +live below this boundary; environment workflows do not distinguish between them. +Backends stop their initialization and installer processes through shared runtime +helpers, without invoking owner lifecycle operations. + Process inspection and group kill attach to an existing runtime without starting Wine or inspecting FVS mounts. Startup still checks existing Virgo mounts against resolved layers and the private upper when preparing storage. Dropping handles leaves @@ -58,7 +77,7 @@ The callback receives a draft of the latest state under the owner lock. Edit `name`, `programs`, and `environment` directly; errors discard the whole draft. Metadata edits work while running. Call `stop()` before changing environment settings, including through `set_component`, `remove_component`, or `install`. -Storage is fixed at creation. Standard dependencies may be appended; Virgo +The prefix backend is fixed at creation. Standard dependencies may be appended; Virgo selections may also be removed or reordered. Standard changes execute installers before saving and keep direct-write semantics. Virgo creation and edits save selections without building layers or changing private prefix data. Preparation @@ -104,8 +123,10 @@ settings take precedence; execution-owned variables such as metadata, addon selections, registry baseline, and private prefix data while stopped, without WineBridge discovery files. -Bottle configuration uses version 1. `environment.storage` selects Standard or -Virgo; layers are derived from selected addons rather than stored in owner state. +Bottle configuration uses version 1. `EnvironmentConfig::backend` selects +`PrefixBackend::Standard` or `PrefixBackend::Virgo` and is serialized under the existing +`environment.storage` key. Layers are derived from selected addons rather than stored +in owner state. Completed addon caches remain reusable. Snapshots stop the runtime and capture existing configuration, registry baseline, and private data without preparing Virgo. Pending selections remain pending after restoration and are diff --git a/src/bottle/edit.rs b/src/bottle/edit.rs index 3620fc1..a2b0b19 100644 --- a/src/bottle/edit.rs +++ b/src/bottle/edit.rs @@ -14,7 +14,7 @@ impl Bottle { /// together; cloned handles serialize edits against the latest state. /// /// Metadata can change while running. Environment changes require an explicit - /// stop first. Storage is fixed; Standard dependencies may only be appended. + /// stop first. The prefix backend is fixed; Standard dependencies may only be appended. /// Addon selections must be downloaded. /// Standard mutations write directly; failed recipes can leave partial effects. /// Virgo edits save selections atomically; preparation happens before startup. @@ -53,36 +53,16 @@ impl Bottle { } program.validate()?; } - draft.environment.validate_requirements()?; - crate::environment::validate_edit( + crate::environment::Environment::apply( &previous.environment, &draft.environment, + &cx.directories().bottle(draft.id), + cx, &bottle.0.addons, - )?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - let root = cx.directories().bottle(draft.id); - let environment_changed = draft.environment != previous.environment; - if environment_changed { - if crate::environment::Environment::try_attach(&root) - .await? - .is_some() - { - return Err(crate::EnvironmentError::MustBeStopped.into()); - } - crate::environment::Environment::stop(&previous.environment, &root, cx).await?; - crate::environment::prefix::reconcile( - &previous.environment, - &draft.environment, - &root, - cx, - &bottle.0.addons, - &progress, - &cancellation, - ) - .await?; - } + &progress, + &cancellation, + ) + .await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } diff --git a/src/bottle/manager.rs b/src/bottle/manager.rs index 374574a..5c412fa 100644 --- a/src/bottle/manager.rs +++ b/src/bottle/manager.rs @@ -17,9 +17,9 @@ use tokio_stream::wrappers::WatchStream; use uuid::Uuid; use crate::{ - Context, EnvironmentConfig, EnvironmentError, Operation, Progress, Stage, Storage, - addons::{Addon, Addons, Requirement, Slot}, - environment::{Environment, prefix}, + Context, EnvironmentConfig, Operation, PrefixBackend, Progress, Stage, + addons::Addons, + environment::Environment, error::{Error, Result}, }; @@ -154,13 +154,13 @@ impl BottleManager { /// /// # Errors /// - /// Returns [`EnvironmentError::RequiresAddon`] with every missing runtime + /// Returns [`crate::EnvironmentError::RequiresAddon`] with every missing runtime /// requirement before creating any files. Other service, I/O, and prefix /// creation failures are returned directly. pub fn create( &self, name: impl Into, - storage: Storage, + backend: PrefixBackend, runner: Uuid, ) -> Operation { let name = name.into(); @@ -169,69 +169,11 @@ impl BottleManager { let registry = self.registry.clone(); Operation::new(move |progress, cancellation| async move { progress.send_replace(Some(Progress::new(Stage::Preparing))); - let runner_component = addons - .component(runner) - .ok_or(crate::AddonError::NotFound(runner))?; - if runner_component.slot() != Slot::Runner { - return Err(EnvironmentError::InvalidComponentSlot { - component: runner_component.id(), - required: Slot::Runner, - } - .into()); - } - let winebridge = addons.latest_component(Slot::WineBridge); - let needs_umu = runner_component - .requirements() - .contains(&Requirement::Slot(Slot::Umu)); - let umu = needs_umu - .then(|| addons.latest_component(Slot::Umu)) - .flatten(); - let mut missing = Vec::new(); - if winebridge.is_none() { - missing.push(Requirement::Slot(Slot::WineBridge)); - } - if needs_umu && umu.is_none() { - missing.push(Requirement::Slot(Slot::Umu)); - } - if !missing.is_empty() { - return Err(EnvironmentError::RequiresAddon { - required_by: None, - requirements: missing, - } - .into()); - } - let winebridge = winebridge.unwrap(); // Safe to unwrap since we just checked it above let id = Uuid::new_v4(); let bottle_path = cx.directories().bottle(id); - - progress.send_replace(Some(Progress::new(Stage::CreatingPrefix))); - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - let mut components = HashMap::from([ - (Slot::WineBridge, Addon::from(winebridge.as_ref())), - (Slot::Runner, Addon::from(runner_component.as_ref())), - ]); - if let Some(umu) = umu { - components.insert(Slot::Umu, Addon::from(umu.as_ref())); - } - let config = EnvironmentConfig { - storage, - components, - dependencies: Vec::new(), - env_vars: Default::default(), - wrappers: Default::default(), - }; - prefix::create(&config.storage, &bottle_path).await?; - // Initialization may retain live storage on failure; keep it outside the removal path. - if matches!(config.storage, Storage::Standard) { - let loaded_runner = config - .runner() - .load_runner(cx.directories(), config.umu()) - .await?; - Environment::initialize(loaded_runner.as_ref(), &bottle_path.join("prefix")) - .await?; - } + // Initialization may retain live storage on failure; only remove after it succeeds. + let config = EnvironmentConfig::new(backend, runner, &addons)?; + Environment::initialize(&config, &bottle_path, &cx, &progress, &cancellation).await?; let result = async { if cancellation.is_cancelled() { return Err(Error::Cancelled); @@ -278,9 +220,8 @@ impl BottleManager { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - let state = bottle.state()?; progress.send_replace(Some(Progress::new(Stage::Stopping))); - Bottle::stop_state(&state, &bottle.0.cx).await?; + bottle.stop_locked().await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } diff --git a/src/bottle/snapshot.rs b/src/bottle/snapshot.rs index d43027e..afb73d1 100644 --- a/src/bottle/snapshot.rs +++ b/src/bottle/snapshot.rs @@ -48,9 +48,8 @@ impl Bottle { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - let state = bottle.state()?; progress.send_replace(Some(Progress::new(Stage::Stopping))); - Bottle::stop_state(&state, &cx).await?; + bottle.stop_locked().await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } @@ -131,9 +130,8 @@ impl Bottle { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - let state = bottle.state()?; progress.send_replace(Some(Progress::new(Stage::Stopping))); - Bottle::stop_state(&state, &cx).await?; + bottle.stop_locked().await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } diff --git a/src/bottle/software.rs b/src/bottle/software.rs index 456c9c9..3128beb 100644 --- a/src/bottle/software.rs +++ b/src/bottle/software.rs @@ -1,70 +1,42 @@ -//! Public bottle operations serialized around temporary environment connections. +//! Owner coordination and registration lookup around environment operations. -use std::ops::AsyncFnOnce; - -use uuid::Uuid; - -use super::{Bottle, BottleState, error::BottleError}; +use super::{Bottle, error::BottleError}; use crate::{ - Context, Operation, ProgramSpec, Progress, Slot, Stage, + Operation, ProgramSpec, Progress, Slot, Stage, environment::Environment, error::{Error, Result}, proto::{DllOverride, DllOverrideMode, Process}, }; +use std::future::Future; +use uuid::Uuid; impl Bottle { - /// Lists Wine DLL overrides, starting the environment if necessary. - pub async fn dll_overrides(&self) -> Result> { + /// Lists Wine DLL overrides, reporting progress while starting the environment if needed. + pub fn dll_overrides(&self) -> Operation> { self.with_environment(async |environment| environment.dll_overrides().await) - .await } - /// Sets a Wine DLL loading mode, starting the environment if necessary. - pub async fn set_dll_override( - &self, - dll: impl Into, - mode: DllOverrideMode, - ) -> Result<()> { + /// Sets a Wine DLL loading mode, reporting progress during environment preparation. + pub fn set_dll_override(&self, dll: impl Into, mode: DllOverrideMode) -> Operation<()> { if mode == DllOverrideMode::Unspecified { - return Err(crate::EnvironmentError::DllOverrideModeRequired.into()); + return Operation::new(|_, _| async { + Err(crate::EnvironmentError::DllOverrideModeRequired.into()) + }); } let dll = dll.into(); self.with_environment(async move |environment| { environment.set_dll_override(dll, mode).await }) - .await } - /// Removes a Wine DLL override. Removing a missing override succeeds. - pub async fn unset_dll_override(&self, dll: impl Into) -> Result<()> { + /// Removes a Wine DLL override, reporting preparation progress. Missing overrides succeed. + pub fn unset_dll_override(&self, dll: impl Into) -> Operation<()> { let dll = dll.into(); self.with_environment(async move |environment| environment.unset_dll_override(dll).await) - .await } - /// Launches the latest registration with this bottle's execution settings. - /// - /// The lazy operation resolves the definition under the owner lock and returns - /// the initial Windows process ID. The process continues after it completes. + /// Resolves the latest registration under the owner lock before starting and launching. pub fn launch_program(&self, id: Uuid) -> Operation { - self.launch_with(move |state| { - state - .program(id) - .cloned() - .ok_or_else(|| BottleError::ProgramNotFound(id).into()) - }) - } - - /// Runs an unregistered launch definition with this bottle's settings. - /// This does not add a library entry. The UUID still identifies its process group. - pub fn launch(&self, program: ProgramSpec) -> Operation { - self.launch_with(move |_| Ok(program)) - } - - fn launch_with( - &self, - resolve: impl FnOnce(&BottleState) -> Result + Send + 'static, - ) -> Operation { let bottle = self.clone(); Operation::new(move |progress, cancellation| async move { progress.send_replace(Some(Progress::new(Stage::Preparing))); @@ -76,83 +48,56 @@ impl Bottle { return Err(Error::Cancelled); } let state = bottle.state()?; - let program = resolve(&state)?; - let environment = bottle.attach_or_start(&progress, &cancellation).await?; - environment.launch_program(&program, &cancellation).await + let program = state.program(id).ok_or(BottleError::ProgramNotFound(id))?; + let environment = Environment::attach_or_start( + &state.environment, + &bottle.0.cx.directories().bottle(state.id), + &bottle.0.cx, + &bottle.0.addons, + &progress, + &cancellation, + ) + .await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + environment.launch(program).await }) } + /// Runs an unregistered definition. Its UUID identifies the process group. + pub fn launch(&self, program: ProgramSpec) -> Operation { + self.with_environment(async move |environment| environment.launch(&program).await) + } + /// Returns Windows processes without starting a stopped environment. pub async fn processes(&self) -> Result> { let _control = self.0.control.lock().await; let state = self.state()?; - match Environment::try_attach(&self.0.cx.directories().bottle(state.id)).await? { - Some(environment) => environment.processes().await, - None => Ok(Vec::new()), - } + Environment::processes(&self.0.cx.directories().bottle(state.id)).await } - /// Terminates a registered program's UUID-keyed process group. - /// A stopped environment is left stopped; a running environment remains available. + /// Terminates a registered program's UUID-keyed process group without starting Wine. pub async fn kill_program(&self, id: Uuid) -> Result<()> { let _control = self.0.control.lock().await; let state = self.state()?; if state.program(id).is_none() { return Err(BottleError::ProgramNotFound(id).into()); } - if let Some(environment) = - Environment::try_attach(&self.0.cx.directories().bottle(state.id)).await? - { - environment.kill(id).await?; - } - Ok(()) + Environment::kill(&self.0.cx.directories().bottle(state.id), id).await } - /// Stops WineBridge, wineserver and storage without requiring attachment. - /// Storage is released only after shutdown succeeds. + /// Stops Wine before releasing storage, even when WineBridge cannot be reached. pub async fn stop(&self) -> Result<()> { let _control = self.0.control.lock().await; - let state = self.state()?; - Self::stop_state(&state, &self.0.cx).await + self.stop_locked().await } /// Selects a downloaded component in a stopped environment. /// A runner requiring UMU selects the latest downloaded UMU if necessary. pub fn set_component(&self, id: Uuid) -> Operation<()> { let addons = self.0.addons.clone(); - self.edit(move |state| { - let component = addons - .component(id) - .ok_or(crate::AddonError::NotFound(id))?; - let config = &mut state.environment; - if config - .component(component.slot()) - .is_some_and(|old| old.id() == id) - { - return Ok(()); - } - let needs_umu = component - .requirements() - .contains(&crate::Requirement::Slot(Slot::Umu)); - if needs_umu && config.umu().is_none() { - let umu = addons.latest_component(Slot::Umu).ok_or_else(|| { - crate::EnvironmentError::RequiresAddon { - required_by: Some(id), - requirements: vec![crate::Requirement::Slot(Slot::Umu)], - } - })?; - config - .components - .insert(Slot::Umu, crate::Addon::from(umu.as_ref())); - } - config - .components - .insert(component.slot(), crate::Addon::from(component.as_ref())); - if component.slot() == Slot::Runner && !needs_umu { - config.components.remove(&Slot::Umu); - } - Ok(()) - }) + self.edit(move |state| state.environment.set_component(id, &addons)) } /// Removes a component from a stopped environment unless another addon requires it. @@ -185,41 +130,50 @@ impl Bottle { }) } - pub(super) async fn stop_state(state: &BottleState, cx: &Context) -> Result<()> { - Environment::stop(&state.environment, &cx.directories().bottle(state.id), cx).await - } - - // Caller holds the owner lock. Attachment leaves a running environment untouched. - async fn attach_or_start( - &self, - progress: &tokio::sync::watch::Sender>, - cancellation: &tokio_util::sync::CancellationToken, - ) -> Result { + // Caller must hold the owner lock, including through subsequent filesystem work. + pub(super) async fn stop_locked(&self) -> Result<()> { let state = self.state()?; - let root = self.0.cx.directories().bottle(state.id); - if let Some(environment) = Environment::try_attach(&root).await? { - return Ok(environment); - } - Environment::start( + Environment::stop( &state.environment, - &root, + &self.0.cx.directories().bottle(state.id), &self.0.cx, - &self.0.addons, - progress, - cancellation, ) .await } - async fn with_environment(&self, work: F) -> Result + /// Execute against a running environment while holding the owner lock. + fn with_environment( + &self, + work: impl FnOnce(Environment) -> Fut + Send + 'static, + ) -> Operation where - F: for<'a> AsyncFnOnce(&'a Environment) -> Result, + T: Send + 'static, + Fut: Future> + Send + 'static, { - let _control = self.0.control.lock().await; - let (progress, _) = tokio::sync::watch::channel(None); - let environment = self - .attach_or_start(&progress, &tokio_util::sync::CancellationToken::new()) + let bottle = self.clone(); + Operation::new(move |progress, cancellation| async move { + progress.send_replace(Some(Progress::new(Stage::Preparing))); + let _control = cancellation + .run_until_cancelled(bottle.0.control.lock()) + .await + .ok_or(Error::Cancelled)?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let state = bottle.state()?; + let environment = Environment::attach_or_start( + &state.environment, + &bottle.0.cx.directories().bottle(state.id), + &bottle.0.cx, + &bottle.0.addons, + &progress, + &cancellation, + ) .await?; - work(&environment).await + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + work(environment).await + }) } } diff --git a/src/bottle/tests.rs b/src/bottle/tests.rs index c406803..0c3aafd 100644 --- a/src/bottle/tests.rs +++ b/src/bottle/tests.rs @@ -7,7 +7,7 @@ use tokio::sync::{Mutex, watch}; use super::state::BottleInner; use crate::{ - Context, Directories, EnvironmentError, Storage, + Context, Directories, EnvironmentError, PrefixBackend, addons::{AddonError, Addons, CatalogError, Requirement, Slot}, bottle::{Bottle, BottleManager}, error::Error, @@ -147,7 +147,10 @@ fn create_reports_all_missing_runtime_addons_before_creating_files() { )); let manager = BottleManager::new(context, addons); - let error = match manager.create("test", Storage::Standard, runner_id).await { + let error = match manager + .create("test", PrefixBackend::Standard, runner_id) + .await + { Ok(_) => panic!("creation should fail before mutation"), Err(error) => error, }; diff --git a/src/environment/config.rs b/src/environment/config.rs index 2188745..0d4eca9 100644 --- a/src/environment/config.rs +++ b/src/environment/config.rs @@ -1,7 +1,9 @@ //! Persisted execution settings shared by all environment owners. -use super::{EnvironmentError, Storage}; -use crate::{Addon, Component, Dependency, EnvVars, Requirement, Slot, Wrappers, error::Result}; +use super::{EnvironmentError, PrefixBackend}; +use crate::{ + Addon, AddonError, Component, Dependency, EnvVars, Requirement, Slot, Wrappers, error::Result, +}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use strum::IntoEnumIterator; @@ -11,7 +13,9 @@ use uuid::Uuid; /// Virgo layers and recipe variables are derived from these selections on demand. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct EnvironmentConfig { - pub storage: Storage, + /// Prefix creation and software materialization strategy. + #[serde(rename = "storage")] + pub backend: PrefixBackend, /// Component releases pinned to their occupied slots. pub components: HashMap>, /// Installed dependencies in installation order. @@ -23,6 +27,150 @@ pub struct EnvironmentConfig { } impl EnvironmentConfig { + /// Resolve the downloaded runtime releases for a new environment. + pub(crate) fn new( + backend: PrefixBackend, + runner: Uuid, + addons: &crate::Addons, + ) -> Result { + let runner_component = addons + .component(runner) + .ok_or(crate::AddonError::NotFound(runner))?; + if runner_component.slot() != Slot::Runner { + return Err(EnvironmentError::InvalidComponentSlot { + component: runner_component.id(), + required: Slot::Runner, + } + .into()); + } + let winebridge = addons.latest_component(Slot::WineBridge); + let needs_umu = runner_component + .requirements() + .contains(&Requirement::Slot(Slot::Umu)); + let umu = needs_umu + .then(|| addons.latest_component(Slot::Umu)) + .flatten(); + let mut missing = Vec::new(); + if winebridge.is_none() { + missing.push(Requirement::Slot(Slot::WineBridge)); + } + if needs_umu && umu.is_none() { + missing.push(Requirement::Slot(Slot::Umu)); + } + if !missing.is_empty() { + return Err(EnvironmentError::RequiresAddon { + required_by: None, + requirements: missing, + } + .into()); + } + let winebridge = winebridge.unwrap(); // Safe to unwrap since we just checked it above + let mut components = HashMap::from([ + (Slot::WineBridge, Addon::from(winebridge.as_ref())), + (Slot::Runner, Addon::from(runner_component.as_ref())), + ]); + if let Some(umu) = umu { + components.insert(Slot::Umu, Addon::from(umu.as_ref())); + } + let config = EnvironmentConfig { + backend, + components, + dependencies: Vec::new(), + env_vars: Default::default(), + wrappers: Default::default(), + }; + config.validate_requirements()?; + Ok(config) + } + + /// Select a downloaded component and pair a runner with UMU when required. + pub(crate) fn set_component(&mut self, id: Uuid, addons: &crate::Addons) -> Result<()> { + let component = addons + .component(id) + .ok_or(crate::AddonError::NotFound(id))?; + if self + .component(component.slot()) + .is_some_and(|old| old.id() == id) + { + return Ok(()); + } + let needs_umu = component + .requirements() + .contains(&crate::Requirement::Slot(Slot::Umu)); + if needs_umu && self.umu().is_none() { + let umu = addons.latest_component(Slot::Umu).ok_or_else(|| { + crate::EnvironmentError::RequiresAddon { + required_by: Some(id), + requirements: vec![crate::Requirement::Slot(Slot::Umu)], + } + })?; + self.components + .insert(Slot::Umu, crate::Addon::from(umu.as_ref())); + } + self.components + .insert(component.slot(), crate::Addon::from(component.as_ref())); + if component.slot() == Slot::Runner && !needs_umu { + self.components.remove(&Slot::Umu); + } + Ok(()) + } + + /// Validate edited selections before runtime or prefix work. + pub(crate) fn validate_edit(&self, previous: &Self, addons: &crate::Addons) -> Result<()> { + self.validate_requirements()?; + if self.backend != previous.backend { + return Err( + EnvironmentError::InvalidEdit("prefix backend is fixed at creation").into(), + ); + } + for slot in Slot::iter() { + let old = previous.component(slot); + let new = self.component(slot); + if old == new { + continue; + } + if let Some(new) = new { + let downloaded = addons + .component(new.id()) + .ok_or(AddonError::NotFound(new.id()))?; + if Addon::from(downloaded.as_ref()) != *new { + return Err(EnvironmentError::InvalidEdit( + "component selection must match its downloaded release", + ) + .into()); + } + } + } + for new in &self.dependencies { + if self + .dependencies + .iter() + .filter(|addon| addon.id() == new.id()) + .count() + != 1 + { + return Err(EnvironmentError::InvalidEdit( + "a dependency may only be selected once", + ) + .into()); + } + if previous.dependency(new.id()) == Some(new) { + continue; + } + let downloaded = addons + .dependency(new.id()) + .ok_or(AddonError::NotFound(new.id()))?; + if Addon::from(downloaded.as_ref()) != *new { + return Err(EnvironmentError::InvalidEdit( + "dependency selection must match its downloaded release", + ) + .into()); + } + } + + Ok(()) + } + /// Prefix-contributing components in fixed slot order. pub(crate) fn ordered_components(&self) -> impl Iterator> { Slot::iter() diff --git a/src/environment/mod.rs b/src/environment/mod.rs index 77d50e3..4873511 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -1,70 +1,64 @@ -//! Shared execution configuration and temporary connections to a running environment. - -#[cfg(feature = "fvs")] -pub(crate) mod history; +//! Running execution environments and lifecycle workflows driven by owner-held configuration. +//! Owners serialize access and persist state; prefix backends materialize execution settings. mod config; mod error; -pub(crate) mod prefix; -mod software; - -pub(crate) use software::validate_edit; - -use std::path::Path; - -use tokio::sync::watch; -use tokio_util::sync::CancellationToken; +#[cfg(feature = "fvs")] +pub(crate) mod history; +mod prefix; +mod runtime; use crate::{ - Context, ProgramSpec, Progress, + Addons, Context, ProgramSpec, Progress, Stage, error::{Error, Result}, proto::{DllOverride, DllOverrideMode, Process}, - runner::Runner, winebridge::WineBridgeClient, }; +use std::path::Path; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; pub use config::EnvironmentConfig; pub use error::EnvironmentError; -pub use prefix::Storage; +pub use prefix::PrefixBackend; +#[cfg(feature = "fvs")] +pub use prefix::VirgoError; -/// A private connection to a live runtime for one control operation. -/// Dropping it only releases local resources. -/// Owners serialize access and persist configuration, including storage metadata. +/// A temporary connection to a running execution environment. +/// Successful construction establishes a WineBridge connection. The runtime may +/// later exit; operations then return connection errors. Dropping this handle +/// releases only the connection, and the owner retains configuration and locking. pub(crate) struct Environment { bridge: WineBridgeClient, } impl Environment { - pub(crate) async fn initialize(runner: &dyn Runner, prefix: &Path) -> Result<()> { - let initialized = runner.wineboot(prefix, "--init").await; - shutdown_wine(runner, prefix).await?; - initialized - } - - /// Stops Wine and unmounts storage without requiring a live handle or bridge. - pub(crate) async fn stop(config: &EnvironmentConfig, root: &Path, cx: &Context) -> Result<()> { - let prefix = root.join("prefix"); - // A lazily created Virgo owner has no runtime mountpoint yet. - if !crate::utils::exists(&prefix).await? { - return Ok(()); - } - let runner = config - .runner() - .load_runner(cx.directories(), config.umu()) - .await?; - shutdown_wine(runner.as_ref(), &prefix).await?; - prefix::stop(&config.storage, root, cx).await + /// Connect to a running environment without starting Wine or preparing storage. + /// Missing discovery returns `None`; malformed or + /// unreachable discovery retains the underlying bridge error. + pub(crate) async fn try_attach(root: &Path) -> Result> { + Ok(WineBridgeClient::try_connect(&root.join("prefix")) + .await? + .map(|bridge| Self { bridge })) } - /// Materializes pending selections, then mounts and starts the selected runtime. - pub(crate) async fn start( + /// Attach after an application restart or prepare and start a stopped runtime. + /// A live attachment bypasses addon resolution, runner loading, and prefix preparation. + pub(crate) async fn attach_or_start( config: &EnvironmentConfig, root: &Path, cx: &Context, - addons: &crate::Addons, + addons: &Addons, progress: &watch::Sender>, cancellation: &CancellationToken, ) -> Result { + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + if let Some(environment) = Self::try_attach(root).await? { + return Ok(environment); + } let env_vars = config.addon_env_vars(addons)?; if cancellation.is_cancelled() { return Err(Error::Cancelled); @@ -77,7 +71,18 @@ impl Environment { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - prefix::prepare(config, root, cx, addons, progress, cancellation).await?; + config + .backend + .prepare( + config, + runner.as_ref(), + root, + cx, + addons, + progress, + cancellation, + ) + .await?; let prefix = root.join("prefix"); let command = config.wrappers.apply(WineBridgeClient::command( runner.as_ref(), @@ -85,24 +90,17 @@ impl Environment { config.winebridge().path(cx.directories()), env_vars.iter().chain(config.env_vars.iter()), )); - let bridge = match WineBridgeClient::connect_or_spawn(&prefix, command).await { - Ok(bridge) => bridge, + match WineBridgeClient::connect_or_spawn(&prefix, command).await { + Ok(bridge) => Ok(Self { bridge }), Err(error) => { - Self::stop(config, root, cx).await?; - return Err(error); + runtime::stop(runner.as_ref(), &prefix).await?; + config.backend.release(root, cx).await?; + Err(error) } - }; - Ok(Self { bridge }) + } } - pub(crate) async fn launch_program( - &self, - program: &ProgramSpec, - cancellation: &CancellationToken, - ) -> Result { - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } + pub(crate) async fn launch(&self, program: &ProgramSpec) -> Result { self.bridge .launch_process( program.id(), @@ -114,58 +112,99 @@ impl Environment { .await } - /// Attaches without starting Wine or mounting storage. - pub(crate) async fn try_attach(root: &Path) -> Result> { - let Some(bridge) = WineBridgeClient::try_connect(&root.join("prefix")).await? else { - return Ok(None); - }; - Ok(Some(Self { bridge })) + pub(crate) async fn dll_overrides(&self) -> Result> { + self.bridge.list_dll_overrides().await } - pub(crate) async fn processes(&self) -> Result> { - self.bridge.list_processes().await + pub(crate) async fn set_dll_override(&self, dll: String, mode: DllOverrideMode) -> Result<()> { + self.bridge.set_dll_override(dll, mode).await } - pub(crate) async fn kill(&self, id: uuid::Uuid) -> Result<()> { - self.bridge.kill_process(id).await + pub(crate) async fn unset_dll_override(&self, dll: String) -> Result<()> { + self.bridge.delete_dll_override(dll).await } - pub(crate) async fn dll_overrides(&self) -> Result> { - match self.bridge.list_dll_overrides().await { - Ok(overrides) => Ok(overrides), - Err(Error::Status(status)) if status.code() == tonic::Code::NotFound => Ok(Vec::new()), - Err(error) => Err(error), + /// Initialize prefix data without returning a running environment. + /// Failed initialization may retain live storage; the owner must not remove its + /// directory unless this function succeeds. + pub(crate) async fn initialize( + config: &EnvironmentConfig, + root: &Path, + cx: &Context, + progress: &watch::Sender>, + cancellation: &CancellationToken, + ) -> Result<()> { + progress.send_replace(Some(Progress::new(Stage::CreatingPrefix))); + if cancellation.is_cancelled() { + return Err(Error::Cancelled); } + config.backend.create(config, root, cx).await } - pub(crate) async fn set_dll_override(&self, dll: String, mode: DllOverrideMode) -> Result<()> { - self.bridge.set_dll_override(dll, mode).await + /// Inspect processes without starting Wine or preparing prefix storage. + pub(crate) async fn processes(root: &Path) -> Result> { + match Self::try_attach(root).await? { + Some(environment) => environment.bridge.list_processes().await, + None => Ok(Vec::new()), + } } - pub(crate) async fn unset_dll_override(&self, dll: String) -> Result<()> { - match self.bridge.delete_dll_override(dll).await { - Err(Error::Status(status)) if status.code() == tonic::Code::NotFound => Ok(()), - result => result, + /// Terminate a UUID-keyed process group without starting a stopped runtime. + pub(crate) async fn kill(root: &Path, id: Uuid) -> Result<()> { + if let Some(environment) = Self::try_attach(root).await? { + environment.bridge.kill_process(id).await?; } + Ok(()) } -} -/// Stops WineBridge and waits for wineserver; the caller owns storage cleanup. -async fn shutdown_wine(runner: &dyn Runner, prefix: &Path) -> Result<()> { - if let Err(error) = WineBridgeClient::shutdown_existing(prefix).await { - tracing::debug!(%error, "WineBridge shutdown failed; stopping wineserver"); + /// Stop Wine before releasing prefix storage, even when WineBridge is unreachable. + pub(crate) async fn stop(config: &EnvironmentConfig, root: &Path, cx: &Context) -> Result<()> { + let prefix = root.join("prefix"); + // No runtime exists until the backend has materialized a prefix. + if !crate::utils::exists(&prefix).await? { + return Ok(()); + } + let runner = config + .runner() + .load_runner(cx.directories(), config.umu()) + .await?; + runtime::stop(runner.as_ref(), &prefix).await?; + config.backend.release(root, cx).await } - for argument in ["-k", "-w"] { - runner - .wineserver(prefix, argument) + + /// Apply a candidate to stopped prefix data; the owner persists and publishes it afterward. + pub(crate) async fn apply( + previous: &EnvironmentConfig, + candidate: &EnvironmentConfig, + root: &Path, + cx: &Context, + addons: &Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, + ) -> Result<()> { + candidate.validate_edit(previous, addons)?; + candidate.backend.validate_edit(previous, candidate)?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + if candidate == previous { + return Ok(()); + } + if Self::try_attach(root).await?.is_some() { + return Err(EnvironmentError::MustBeStopped.into()); + } + Self::stop(previous, root, cx).await?; + candidate + .backend + .apply( + previous, + candidate, + root, + cx, + addons, + progress, + cancellation, + ) .await - .map_err(|source| EnvironmentError::Cleanup { - prefix: prefix.to_path_buf(), - source: Box::new(source), - })?; - } - if let Err(error) = WineBridgeClient::clear_discovery(prefix).await { - tracing::warn!(%error, "could not remove WineBridge discovery after shutdown"); } - Ok(()) } diff --git a/src/environment/prefix/mod.rs b/src/environment/prefix/mod.rs index cd7b331..110705b 100644 --- a/src/environment/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -1,117 +1,123 @@ -//! Prefix preparation and storage backends. -//! -//! Standard storage mutates a conventional prefix directly; Virgo stores an -//! ordered FVS layer stack with a private writable upper directory. Virgo -//! materialization uses rollback checkpoints; Standard uses FVS only for explicit snapshots. +//! Prefix backends own initialization, software materialization, and storage release. +//! Environment stops the owner's runtime before calling apply, prepare, or release. +//! Backends stop their own initialization and installer processes before returning. mod standard; #[cfg(feature = "fvs")] mod virgo; - -use std::path::Path; - #[cfg(feature = "fvs")] pub use virgo::VirgoError; +use super::EnvironmentConfig; +use crate::{Addons, Context, Progress, error::Result, runner::Runner}; use serde::{Deserialize, Serialize}; +use std::path::Path; use tokio::sync::watch; use tokio_util::sync::CancellationToken; -use crate::{Addons, Context, EnvironmentConfig, Progress, error::Result}; - #[cfg(feature = "fvs")] -pub(crate) const FVS_BLOCK_SIZE: u32 = 1024 * 1024; +pub(super) const FVS_BLOCK_SIZE: u32 = 1024 * 1024; -/// Selects conventional mutable storage or FVS composition. +/// Selects how a runnable Wine prefix is created and maintained. #[derive(Debug, Clone, Deserialize, PartialEq, Serialize)] -pub enum Storage { - /// Stores a conventional mutable prefix in the owner directory. - /// +pub enum PrefixBackend { + /// Initialize and mutate a conventional prefix directly. /// Explicit snapshots may use FVS; ordinary mutations use direct writes. Standard, - /// Stores the prefix as composable FVS layers. - /// + /// Build immutable artifacts and compose them with a private writable upper. /// Virgo is experimental and requires the configured FVS service. #[cfg(feature = "fvs")] Virgo, } -/// Creates storage at an explicit owner location. -pub(crate) async fn create(storage: &Storage, root: &Path) -> Result<()> { - let directory = match storage { - Storage::Standard => "prefix", - #[cfg(feature = "fvs")] - Storage::Virgo => "upper", - }; - Ok(async_fs::create_dir_all(root.join(directory)).await?) -} +impl PrefixBackend { + /// Create initial prefix data. A successful return leaves no Wine processes running. + /// Failed cleanup retains data for explicit recovery by the caller. + pub(super) async fn create( + &self, + config: &EnvironmentConfig, + root: &Path, + cx: &Context, + ) -> Result<()> { + match self { + Self::Standard => standard::create(config, root, cx).await, + #[cfg(feature = "fvs")] + Self::Virgo => Ok(async_fs::create_dir_all(root.join("upper")).await?), + } + } -/// Checks backend restrictions before the owner stops or publishes an edit. -pub(crate) fn validate_edit( - previous: &EnvironmentConfig, - candidate: &EnvironmentConfig, -) -> Result<()> { - if matches!(candidate.storage, Storage::Standard) - && !candidate.dependencies.starts_with(&previous.dependencies) - { - return Err(crate::EnvironmentError::InvalidEdit( - "installed dependencies cannot be removed, replaced or reordered", - ) - .into()); + /// Check backend-specific edit restrictions before the owner is stopped or changed. + pub(super) fn validate_edit( + &self, + previous: &EnvironmentConfig, + candidate: &EnvironmentConfig, + ) -> Result<()> { + match self { + Self::Standard => standard::validate_edit(previous, candidate), + #[cfg(feature = "fvs")] + Self::Virgo => Ok(()), + } } - Ok(()) -} -/// Applies edited selections to a stopped prefix; Virgo defers work until preparation. -pub(crate) async fn reconcile( - previous: &EnvironmentConfig, - candidate: &EnvironmentConfig, - root: &Path, - cx: &Context, - addons: &Addons, - progress: &watch::Sender>, - cancellation: &CancellationToken, -) -> Result<()> { - match candidate.storage { - Storage::Standard => { - standard::reconcile( - previous, - candidate, - root, - cx, - addons, - progress, - cancellation, - ) - .await + /// Apply validated selections to a stopped prefix. Virgo defers materialization. + pub(super) async fn apply( + &self, + previous: &EnvironmentConfig, + candidate: &EnvironmentConfig, + root: &Path, + cx: &Context, + addons: &Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, + ) -> Result<()> { + match self { + Self::Standard => { + standard::apply( + previous, + candidate, + root, + cx, + addons, + progress, + cancellation, + ) + .await + } + #[cfg(feature = "fvs")] + Self::Virgo => Ok(()), } - #[cfg(feature = "fvs")] - Storage::Virgo => Ok(()), } -} -/// Prepares a stopped owner's prefix for execution. -pub(crate) async fn prepare( - config: &EnvironmentConfig, - root: &Path, - cx: &Context, - addons: &Addons, - progress: &watch::Sender>, - cancellation: &CancellationToken, -) -> Result<()> { - let _ = (root, cx, addons, progress, cancellation); - match config.storage { - Storage::Standard => Ok(()), - #[cfg(feature = "fvs")] - Storage::Virgo => virgo::prepare(config, root, cx, addons, progress, cancellation).await, + /// Materialize a stopped prefix for execution using the selected configuration. + pub(super) async fn prepare( + &self, + config: &EnvironmentConfig, + runner: &dyn Runner, + root: &Path, + cx: &Context, + addons: &Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, + ) -> Result<()> { + #[cfg(not(feature = "fvs"))] + let _ = (config, runner, root, cx, addons, progress, cancellation); + match self { + Self::Standard => Ok(()), + #[cfg(feature = "fvs")] + Self::Virgo => { + virgo::prepare(config, runner, root, cx, addons, progress, cancellation).await + } + } } -} -pub(crate) async fn stop(storage: &Storage, root: &Path, context: &Context) -> Result<()> { - let _ = (root, context); - match storage { - Storage::Standard => Ok(()), - #[cfg(feature = "fvs")] - Storage::Virgo => virgo::stop(root, context).await, + /// Release prefix storage only after environment has finished process shutdown. + pub(super) async fn release(&self, root: &Path, cx: &Context) -> Result<()> { + #[cfg(not(feature = "fvs"))] + let _ = (root, cx); + match self { + Self::Standard => Ok(()), + #[cfg(feature = "fvs")] + Self::Virgo => virgo::release(root, cx).await, + } } } diff --git a/src/environment/prefix/standard.rs b/src/environment/prefix/standard.rs index f961c18..4c53f5c 100644 --- a/src/environment/prefix/standard.rs +++ b/src/environment/prefix/standard.rs @@ -1,20 +1,41 @@ -//! Direct installation into a conventional mutable prefix. +//! Initialize and mutate a conventional Wine prefix directly. +use super::super::runtime; +use crate::{ + AddonError, Addons, Context, EnvironmentConfig, EnvironmentError, Progress, Slot, Stage, + addons::{InstallInputs, execute, uninstall}, + error::Result, +}; use std::path::Path; - use strum::IntoEnumIterator; use tokio::sync::watch; use tokio_util::sync::CancellationToken; -use crate::{ - AddonError, Addons, Context, EnvironmentConfig, Progress, Slot, Stage, - addons::{InstallInputs, execute, uninstall}, - environment::Environment, - error::Result, -}; +pub(super) async fn create(config: &EnvironmentConfig, root: &Path, cx: &Context) -> Result<()> { + let prefix = root.join("prefix"); + async_fs::create_dir_all(&prefix).await?; + let runner = config + .runner() + .load_runner(cx.directories(), config.umu()) + .await?; + runtime::initialize(runner.as_ref(), &prefix).await +} + +pub(super) fn validate_edit( + previous: &EnvironmentConfig, + candidate: &EnvironmentConfig, +) -> Result<()> { + if !candidate.dependencies.starts_with(&previous.dependencies) { + return Err(EnvironmentError::InvalidEdit( + "installed dependencies cannot be removed, replaced or reordered", + ) + .into()); + } + Ok(()) +} /// Apply validated Standard selections directly to the stopped owner's prefix. -pub(super) async fn reconcile( +pub(super) async fn apply( previous: &EnvironmentConfig, candidate: &EnvironmentConfig, root: &Path, @@ -70,7 +91,7 @@ pub(super) async fn reconcile( }, ) .await; - Environment::stop(candidate, root, cx).await?; + runtime::stop(runner.as_ref(), &prefix).await?; result?; } for resources in installations { @@ -89,7 +110,7 @@ pub(super) async fn reconcile( }, ) .await; - Environment::stop(candidate, root, cx).await?; + runtime::stop(runner.as_ref(), &prefix).await?; result?; } Ok(()) diff --git a/src/environment/prefix/virgo/artifacts/cache.rs b/src/environment/prefix/virgo/artifacts/cache.rs index e3956e9..f314f4d 100644 --- a/src/environment/prefix/virgo/artifacts/cache.rs +++ b/src/environment/prefix/virgo/artifacts/cache.rs @@ -20,7 +20,7 @@ use crate::{ runner::Runner, }; -use crate::environment::prefix::{FVS_BLOCK_SIZE, VirgoError}; +use crate::environment::{VirgoError, prefix::FVS_BLOCK_SIZE}; /// Checks only for FVS repository metadata; [`layer`] validates its commit. pub(crate) async fn exists(id: Uuid, context: &Context) -> Result { @@ -88,7 +88,7 @@ where execute(&prefix).await } .await; - crate::environment::shutdown_wine(runner, &prefix).await?; + crate::environment::runtime::stop(runner, &prefix).await?; let diffed: Result<()> = async { installed?; let diff_before = before.clone(); diff --git a/src/environment/prefix/virgo/artifacts/mod.rs b/src/environment/prefix/virgo/artifacts/mod.rs index fd40644..003ac83 100644 --- a/src/environment/prefix/virgo/artifacts/mod.rs +++ b/src/environment/prefix/virgo/artifacts/mod.rs @@ -96,7 +96,7 @@ async fn ensure_base( })?; let initialized = runner.wineboot(&prefix, "--init").await; // Keep storage if Wine cannot be stopped safely. - crate::environment::shutdown_wine(runner.as_ref(), &prefix).await?; + crate::environment::runtime::stop(runner.as_ref(), &prefix).await?; let result = async { initialized?; if cancellation.is_cancelled() { @@ -192,7 +192,7 @@ async fn ensure_adapter( .mount(&mountpoint, vec![base.clone()], Some(&upper)) .await?; let initialized = runner.wineboot(&mountpoint, "--init").await; - crate::environment::shutdown_wine(runner, &mountpoint).await?; + crate::environment::runtime::stop(runner, &mountpoint).await?; client .unmount(&mount, UnmountMode::Normal) .await diff --git a/src/environment/prefix/virgo/mod.rs b/src/environment/prefix/virgo/mod.rs index cd59db5..c448017 100644 --- a/src/environment/prefix/virgo/mod.rs +++ b/src/environment/prefix/virgo/mod.rs @@ -1,63 +1,24 @@ -//! Layered Virgo prefix storage. -//! -//! A mounted prefix combines a shared base, a runner-specific adapter, cached -//! addon layers, and the owner's writable `upper` directory. Layer order is -//! derived from selected addons when preparing a stopped environment. +//! Resolve shared artifacts and prepare a stopped owner's Virgo composition. mod artifacts; mod registry; -use std::path::{Path, PathBuf}; - -use tokio::sync::watch; -use tokio_util::sync::CancellationToken; -use uuid::Uuid; - -use futures_lite::StreamExt; -use fvs_rs::{Layer, UnmountMode}; - +use super::super::{EnvironmentConfig, history}; use crate::{ Context, Progress, Stage, - environment::{EnvironmentConfig, history}, error::{Error, Result}, + runner::Runner, }; - -/// Virgo-specific failures carried by [`crate::error::Error::Virgo`]. -#[derive(Debug, thiserror::Error)] -pub enum VirgoError { - #[error("no Soda runner release in the current component catalog")] - SodaNotInCatalog, - #[error("invalid Soda semantic version: {0}")] - InvalidSodaVersion(String), - #[error("download Soda {version} ({id}) before building the Virgo base or an addon layer")] - SodaNotDownloaded { id: Uuid, version: String }, - - /// A required FVS commit is missing from a repository. - #[error("FVS repository {repository} has no commit {state}")] - MissingCommit { - /// Repository whose history was searched. - repository: PathBuf, - /// Requested full or abbreviated state ID. - state: String, - }, - /// Virgo cannot mount a prefix over a nonempty mountpoint. - #[error("mountpoint is not empty: {0}")] - DirtyMountpoint(PathBuf), - #[error( - "mounted layers or writable upper differ from the selected composition at {0}; call stop() and retry" - )] - MountMismatch(PathBuf), - /// A cached layer required to construct the prefix is missing. - #[error("cached Virgo layer was not found: {0}")] - CachedLayerNotFound(PathBuf), - /// Registry data could not be converted while building a Virgo layer. - #[error("failed to process Virgo registry data: {0}")] - Registry(String), -} +use futures_lite::StreamExt; +use fvs_rs::{Layer, UnmountMode}; +use std::path::Path; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; /// Builds the selected Virgo composition while the owner is coordinated and stopped. pub(super) async fn prepare( config: &EnvironmentConfig, + runner: &dyn Runner, root: &Path, cx: &Context, addons: &crate::Addons, @@ -69,15 +30,11 @@ pub(super) async fn prepare( .map(crate::Addon::id) .chain(config.dependencies.iter().map(crate::Addon::id)) .collect(); - let runner = config - .runner() - .load_runner(cx.directories(), config.umu()) - .await?; for id in &ids { artifacts::prepare_addon(*id, addons, cx, progress, cancellation).await?; } let mut layers = artifacts::base_layers( - runner.as_ref(), + runner, &config.runner().id().to_string(), addons, cx, @@ -108,6 +65,11 @@ pub(super) async fn prepare( } .await; history::recover(result, root, &checkpoint, cx, progress).await?; + mount(root, layers, cx).await +} + +/// Mount resolved layers after the environment workflow has stopped Wine. +async fn mount(root: &Path, layers: Vec, cx: &Context) -> Result<()> { if !existing_mount(root, &layers, cx).await? { let prefix = root.join("prefix"); ensure_empty_dir(&prefix).await?; @@ -137,7 +99,7 @@ async fn existing_mount(root: &Path, layers: &[Layer], context: &Context) -> Res Ok(true) } -pub(super) async fn stop(root: &Path, context: &Context) -> Result<()> { +pub(super) async fn release(root: &Path, context: &Context) -> Result<()> { let prefix = root.join("prefix"); let client = context.fvs().await?; if let Some(mount) = client.list_mounts().await?.into_iter().find(|mount| { @@ -165,3 +127,36 @@ async fn ensure_empty_dir(path: &Path) -> Result<()> { } Ok(()) } + +/// Virgo-specific failures carried by [`crate::error::Error::Virgo`]. +#[derive(Debug, thiserror::Error)] +pub enum VirgoError { + #[error("no Soda runner release in the current component catalog")] + SodaNotInCatalog, + #[error("invalid Soda semantic version: {0}")] + InvalidSodaVersion(String), + #[error("download Soda {version} ({id}) before building the Virgo base or an addon layer")] + SodaNotDownloaded { id: uuid::Uuid, version: String }, + + /// A required FVS commit is missing from a repository. + #[error("FVS repository {repository} has no commit {state}")] + MissingCommit { + /// Repository whose history was searched. + repository: std::path::PathBuf, + /// Requested full or abbreviated state ID. + state: String, + }, + /// Virgo cannot mount a prefix over a nonempty mountpoint. + #[error("mountpoint is not empty: {0}")] + DirtyMountpoint(std::path::PathBuf), + #[error( + "mounted layers or writable upper differ from the selected composition at {0}; call stop() and retry" + )] + MountMismatch(std::path::PathBuf), + /// A cached layer required to construct the prefix is missing. + #[error("cached Virgo layer was not found: {0}")] + CachedLayerNotFound(std::path::PathBuf), + /// Registry data could not be converted while building a Virgo layer. + #[error("failed to process Virgo registry data: {0}")] + Registry(String), +} diff --git a/src/environment/runtime.rs b/src/environment/runtime.rs new file mode 100644 index 0000000..4a1b815 --- /dev/null +++ b/src/environment/runtime.rs @@ -0,0 +1,32 @@ +//! Wine process lifecycle shared by owner workflows and scratch artifact builds. +//! These helpers never mount, unmount, persist, or delete storage. + +use crate::{EnvironmentError, error::Result, runner::Runner, winebridge::WineBridgeClient}; +use std::path::Path; + +/// Initialize Wine and stop its processes, including after initialization fails. +pub(super) async fn initialize(runner: &dyn Runner, prefix: &Path) -> Result<()> { + let initialized = runner.wineboot(prefix, "--init").await; + stop(runner, prefix).await?; + initialized +} + +/// Stops WineBridge and waits for wineserver; the caller owns storage cleanup. +pub(super) async fn stop(runner: &dyn Runner, prefix: &Path) -> Result<()> { + if let Err(error) = WineBridgeClient::shutdown_existing(prefix).await { + tracing::debug!(%error, "WineBridge shutdown failed; stopping wineserver"); + } + for argument in ["-k", "-w"] { + runner + .wineserver(prefix, argument) + .await + .map_err(|source| EnvironmentError::Cleanup { + prefix: prefix.to_path_buf(), + source: Box::new(source), + })?; + } + if let Err(error) = WineBridgeClient::clear_discovery(prefix).await { + tracing::warn!(%error, "could not remove WineBridge discovery after shutdown"); + } + Ok(()) +} diff --git a/src/environment/software.rs b/src/environment/software.rs deleted file mode 100644 index b654b1c..0000000 --- a/src/environment/software.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! Validate edited execution selections before lifecycle or prefix work. - -use strum::IntoEnumIterator; - -use super::{EnvironmentConfig, EnvironmentError}; -use crate::{Addon, AddonError, Addons, Slot, error::Result}; - -/// Validate selections before lifecycle or prefix work. -pub(crate) fn validate_edit( - previous: &EnvironmentConfig, - candidate: &EnvironmentConfig, - addons: &Addons, -) -> Result<()> { - if candidate.storage != previous.storage { - return Err(EnvironmentError::InvalidEdit("storage strategy is fixed at creation").into()); - } - super::prefix::validate_edit(previous, candidate)?; - - for slot in Slot::iter() { - let old = previous.component(slot); - let new = candidate.component(slot); - if old == new { - continue; - } - if let Some(new) = new { - let downloaded = addons - .component(new.id()) - .ok_or(AddonError::NotFound(new.id()))?; - if Addon::from(downloaded.as_ref()) != *new { - return Err(EnvironmentError::InvalidEdit( - "component selection must match its downloaded release", - ) - .into()); - } - } - } - for new in &candidate.dependencies { - if candidate - .dependencies - .iter() - .filter(|addon| addon.id() == new.id()) - .count() - != 1 - { - return Err( - EnvironmentError::InvalidEdit("a dependency may only be selected once").into(), - ); - } - if previous.dependency(new.id()) == Some(new) { - continue; - } - let downloaded = addons - .dependency(new.id()) - .ok_or(AddonError::NotFound(new.id()))?; - if Addon::from(downloaded.as_ref()) != *new { - return Err(EnvironmentError::InvalidEdit( - "dependency selection must match its downloaded release", - ) - .into()); - } - } - - Ok(()) -} diff --git a/src/error.rs b/src/error.rs index 72d2fc6..2c750a3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,7 @@ use thiserror::Error; #[cfg(feature = "fvs")] -pub use crate::environment::prefix::VirgoError; +pub use crate::environment::VirgoError; pub use crate::{ addons::{AddonError, CatalogError, InstallerError}, bottle::error::BottleError, diff --git a/src/lib.rs b/src/lib.rs index e229de6..8694d14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,7 +24,7 @@ pub use bottle::{ #[cfg(feature = "fvs")] pub use bottle::{Snapshot, SnapshotSummary}; pub use core::{Bottles, Config}; -pub use environment::{EnvironmentConfig, EnvironmentError, Storage}; +pub use environment::{EnvironmentConfig, EnvironmentError, PrefixBackend}; pub use library::{Library, LibraryItem, SearchEntry, SearchSource}; pub use operation::{Operation, Progress, Stage, Transfer}; pub use plugins::{PluginError, PluginId, PluginInfo, PluginKind, PluginManifest, Plugins}; diff --git a/src/winebridge.rs b/src/winebridge.rs index 159d151..7214de0 100644 --- a/src/winebridge.rs +++ b/src/winebridge.rs @@ -602,16 +602,18 @@ impl WineBridgeClient { // --- DLL Overrides --- - /// Lists the configured DLL overrides. + /// Lists the configured DLL overrides. A missing override key yields an empty list. /// /// # Errors /// /// Returns an error if the gRPC request fails. pub async fn list_dll_overrides(&self) -> Result> { let mut client = self.client.clone(); - let response = client.list_dll_overrides(()).await?.into_inner(); - - Ok(response.overrides) + match client.list_dll_overrides(()).await { + Ok(response) => Ok(response.into_inner().overrides), + Err(status) if status.code() == tonic::Code::NotFound => Ok(Vec::new()), + Err(status) => Err(status.into()), + } } /// Returns the override mode configured for a single DLL. @@ -649,18 +651,21 @@ impl WineBridgeClient { Ok(()) } - /// Removes a DLL override. + /// Removes a DLL override. A missing override is already removed. /// /// # Errors /// /// Returns an error if the gRPC request fails or WineBridge reports failure. pub async fn delete_dll_override(&self, dll: impl Into) -> Result<()> { let mut client = self.client.clone(); - client + match client .delete_dll_override(proto::DllOverrideRequest { dll: dll.into() }) - .await?; - - Ok(()) + .await + { + Ok(_) => Ok(()), + Err(status) if status.code() == tonic::Code::NotFound => Ok(()), + Err(status) => Err(status.into()), + } } // --- System --- From f07cd1fd80e5e8fba93d8f20ab5b97e1c831e870 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Fri, 11 Sep 2026 23:00:57 +0530 Subject: [PATCH 15/24] refactor(addons): persist immutable releases by UUID --- README.md | 50 ++- src/addons/addon.rs | 20 +- src/addons/catalog.rs | 47 +- src/addons/error.rs | 11 +- src/addons/index/mod.rs | 287 ------------ src/addons/index/rebuild.rs | 193 -------- src/addons/installer/engine.rs | 56 +-- src/addons/installer/mod.rs | 35 +- src/addons/installer/recipes.rs | 9 +- src/addons/manager/catalog.rs | 11 +- src/addons/manager/fetch.rs | 345 +++++---------- src/addons/manager/import.rs | 76 ++++ src/addons/manager/mod.rs | 414 ++++++++++++------ src/addons/mod.rs | 15 +- src/addons/release.rs | 247 +++++++++++ src/bottle/tests.rs | 48 +- src/environment/config.rs | 17 +- src/environment/prefix/standard.rs | 38 +- src/environment/prefix/virgo/artifacts/mod.rs | 4 +- .../prefix/virgo/artifacts/software.rs | 25 +- src/lib.rs | 4 +- src/utils/archive.rs | 5 +- src/utils/directories.rs | 12 +- 23 files changed, 947 insertions(+), 1022 deletions(-) delete mode 100644 src/addons/index/mod.rs delete mode 100644 src/addons/index/rebuild.rs create mode 100644 src/addons/manager/import.rs create mode 100644 src/addons/release.rs diff --git a/README.md b/README.md index aa2b5c0..dbf49c5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The application core for managing Bottles Next Wine and Proton environments. -`bottles-core` discovers and installs managed components, persists bottles, +`bottles-core` imports and installs managed components, persists bottles, executes Windows programs through WineBridge, and provides Virgo storage and snapshots through the default `fvs` feature. Standard creation, launch, and addon changes work without FVS even when that feature is compiled in. @@ -47,8 +47,8 @@ An `Environment` represents a running execution environment and holds a required WineBridge connection. `attach_or_start` constructs it directly from the owner's configuration and location, including after an application restart. The handle retains neither configuration nor services; dropping it leaves Wine running. Launch and DLL -operations use Bottle's `with_environment` helper, which holds the owner lock, resolves -registration inputs before startup, and forwards progress and cancellation. +operations hold the owner lock and forward progress and cancellation. Registered +launch resolves its program before startup; other calls use `with_environment`. Initialization, configuration edits, and shutdown are associated functions on `Environment` that take the owner's inputs and return completion, without requiring @@ -83,6 +83,41 @@ before saving and keep direct-write semantics. Virgo creation and edits save selections without building layers or changing private prefix data. Preparation errors surface when starting the environment. +Local releases live at `components/releases//` or +`dependencies/releases//`. Both `release.toml` and `payload/` are required; +they are published and removed together. `Release` is persisted directly and owns the local resource paths and frozen recipes. +The manager keeps separate typed component and dependency maps, with UUID uniqueness +checked across both families. +Each ordered resource keeps a path relative to `payload/` and its recipe. +Components use the payload directory itself; dependency resources use local filenames. +Download URLs and checksums remain in the catalog and are used only during fetching. `Addon` remains the lightweight +selection in bottle state. A local release exists only as a complete record and +payload. Loading rejects incomplete releases. Removal renames the entire release +directory out of its published location, removes it from the typed map, then deletes +the withdrawn directory. A deletion failure leaves only unpublished staging data. +Existing environments keep their selections and report missing releases when needed. +Fetching a removed release resolves it from the current catalog; imported components +must be imported again with a new UUID. Built Virgo caches are not removed. + +In catalog format 2, omitted component `steps` use the bundled recipe for that slot. +Explicit `steps` replace the default entirely; `steps: []` means no installation steps. +The resolved recipe is frozen into the local release, so template updates do not +change existing releases. Dependency steps come from the catalog (omitted means empty), +and both families take their requirements from the catalog. +Changed contents or recipes require a new UUID. Catalog refresh +only replaces catalog snapshots; it neither changes releases nor discovers files. + +`import_component(path, slot, name, version)` extracts a local `.tar`, `.tar.gz`/`.tgz`, +or `.tar.xz`/`.txz` archive containing one top-level component directory. It assigns a +fresh UUID and freezes a bundled recipe into the release. Folder imports are not +supported. Imports work offline and leave the source archive untouched. Executable +permissions and internal relative symlinks are preserved; escaping links are rejected. +Catalog component downloads and local imports share archive preparation and release +publication; catalog downloads additionally transfer and verify the archive. Templates are never read during installation +or startup. Old indexes, slot/version directories, and the former top-level `releases/` directory +are ignored and left untouched; +re-download or explicitly import a component archive. There is no automatic migration. + Virgo builds a clean shared base from the latest catalog runner named `Soda` (case-insensitive, semantic-version ordering). That exact release must already be downloaded. The base manifest pins its release and immutable FVS revision across @@ -114,10 +149,11 @@ files, updates, saves, and whiteouts keep normal overlay precedence; the upper i never pruned. A later WineBridge startup failure does not undo successful registry preparation. -Recipe environment variables are derived when starting an environment or running -Standard installers; only explicit settings are persisted. Component recipes are -built in, and dependency recipes come from the UUID-pinned local index, not the -current catalog. Missing dependency recipe metadata prevents startup. Explicit +Recipe environment variables are derived from durable release records when starting +an environment or running Standard installers. Both backends require the selected +local releases for these recipes; Virgo caches contain filesystem and registry +effects, not runtime variables. Catalog and bundled-template updates cannot change +a local release's recipe. Explicit settings take precedence; execution-owned variables such as `WINEPREFIX`, `WINEARCH`, and `PROTONPATH` are applied last. Snapshots capture owner metadata, addon selections, registry baseline, and private prefix data while diff --git a/src/addons/addon.rs b/src/addons/addon.rs index dd28825..511d7d4 100644 --- a/src/addons/addon.rs +++ b/src/addons/addon.rs @@ -1,4 +1,4 @@ -//! Artifact-free addon selections and their family discriminators. +//! InstallResource-free addon selections and their family discriminators. use std::{fmt, path::PathBuf, str::FromStr}; @@ -14,7 +14,7 @@ use crate::{ /// An addon selection persisted in a bottle. /// -/// `K` is [`Component`] or [`Dependency`]. Unlike an [`IndexEntry`](super::IndexEntry), +/// `K` is [`Component`] or [`Dependency`]. Unlike an [`Release`](super::Release), /// this value contains no download artifacts; it remains sufficient for requirement /// validation and for locating or removing a selected component. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -49,17 +49,17 @@ impl Addon { } } - /// Returns the release identifier shared by its catalog, index, and bottle records. + /// Returns the release identifier shared by its catalog, release, and bottle records. pub fn id(&self) -> Uuid { self.id.get() } - /// Returns the catalog label, or version directory name for hand-placed components. + /// Returns the release label. pub fn name(&self) -> &str { &self.name } - /// Returns the downloaded catalog or hand-placed version string. + /// Returns the release version string. pub fn version(&self) -> &str { &self.version } @@ -78,9 +78,9 @@ impl Addon { pub(crate) fn path(&self, directories: &Directories) -> PathBuf { directories - .components() - .join(self.slot().as_str()) - .join(self.version()) + .component_releases() + .join(self.id().to_string()) + .join("payload") } /// Reports whether this component satisfies `requirement`. @@ -208,14 +208,14 @@ pub enum Requirement { Id(Uuid), } -/// Type discriminator for component catalog, index, and bottle records. +/// Type discriminator for component catalog, release, and bottle records. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Component { pub(crate) slot: Slot, } -/// Type discriminator for dependency catalog, index, and bottle records. +/// Type discriminator for dependency catalog, release, and bottle records. #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] pub struct Dependency {} diff --git a/src/addons/catalog.rs b/src/addons/catalog.rs index 98634f8..b1e0749 100644 --- a/src/addons/catalog.rs +++ b/src/addons/catalog.rs @@ -99,7 +99,7 @@ enum Architecture { /// One validated, cached catalog document. /// /// Catalog loading is deliberately tolerant: an unavailable or invalid cache -/// is treated as absent so local index entries remain usable. +/// is treated as absent so local release records remain usable. pub(crate) struct Catalog { #[serde(deserialize_with = "deserialize_catalog_version")] schema_version: u32, @@ -148,14 +148,14 @@ pub(crate) struct CatalogUrls { /// Maps a family discriminator to its catalog URL and managed storage files. /// -/// Keeping this mapping on the two runtime families lets catalog and index +/// Keeping this mapping on the two runtime families lets catalog /// persistence share generic code without introducing per-slot component types. pub(crate) trait AddonFamily { const LABEL: &'static str; fn url(urls: &CatalogUrls) -> Option; fn catalog(directories: &Directories) -> PathBuf; - fn index(directories: &Directories) -> PathBuf; + fn releases(directories: &Directories) -> PathBuf; } impl AddonFamily for Component { @@ -169,8 +169,8 @@ impl AddonFamily for Component { directories.components().join("catalog.json") } - fn index(directories: &Directories) -> PathBuf { - directories.components().join("index.toml") + fn releases(directories: &Directories) -> PathBuf { + directories.component_releases() } } @@ -185,8 +185,8 @@ impl AddonFamily for Dependency { directories.dependencies().join("catalog.json") } - fn index(directories: &Directories) -> PathBuf { - directories.dependencies().join("index.toml") + fn releases(directories: &Directories) -> PathBuf { + directories.dependency_releases() } } @@ -203,8 +203,6 @@ pub struct CatalogEntry { name: String, #[serde(deserialize_with = "deserialize_non_empty_string")] version: String, - // Dependency requirements come from the catalog. Component requirements - // are derived from the downloaded release during inspection. #[serde(default, skip_serializing_if = "Vec::is_empty")] requirements: Vec, #[serde(deserialize_with = "deserialize_non_empty_vec")] @@ -214,7 +212,7 @@ pub struct CatalogEntry { } impl CatalogEntry { - /// Returns the identifier used to correlate this release with an index entry. + /// Returns the identifier used to correlate this release with a local release. pub fn id(&self) -> Uuid { self.id.get() } @@ -229,6 +227,11 @@ impl CatalogEntry { &self.version } + /// Requirements owned by this release definition, for either addon family. + pub fn requirements(&self) -> &[Requirement] { + &self.requirements + } + /// Reports whether at least one artifact matches the current build target. /// /// Platform matching is exact. An artifact without a platform restriction @@ -255,19 +258,12 @@ impl CatalogEntry { } } -impl CatalogEntry { - /// Returns the addons that must already be present before installation. - pub fn requirements(&self) -> &[Requirement] { - &self.requirements - } -} - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields)] /// One downloadable file and the recipe associated with it. /// -/// Dependency recipes are retained in the local index. Components are inspected -/// after extraction and use the built-in recipe for their slot instead. +/// Both component and dependency recipes become part of the immutable release. +/// Components are extracted before their recipe is applied. pub(crate) struct CatalogArtifact { url: url::Url, #[serde(deserialize_with = "deserialize_non_empty_string")] @@ -276,25 +272,22 @@ pub(crate) struct CatalogArtifact { checksum: Checksum, #[serde(default, skip_serializing_if = "Option::is_none")] platform: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - steps: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + steps: Option>, } impl CatalogArtifact { - pub(crate) fn url(&self) -> &url::Url { + pub(crate) fn url(&self) -> &Url { &self.url } - pub(crate) fn file_name(&self) -> &str { &self.file_name } - pub(crate) fn checksum(&self) -> &Checksum { &self.checksum } - - pub(crate) fn steps(&self) -> &[InstallStep] { - &self.steps + pub(crate) fn steps(&self) -> Option<&[InstallStep]> { + self.steps.as_deref() } fn matches(&self, target: Target) -> bool { diff --git a/src/addons/error.rs b/src/addons/error.rs index 0efce82..57a6eeb 100644 --- a/src/addons/error.rs +++ b/src/addons/error.rs @@ -25,6 +25,9 @@ pub enum AddonError { /// The requested release is unknown or not present on disk for removal. #[error("addon {0} was not found")] NotFound(Uuid), + /// A local release is incomplete or its payload was removed outside the manager. + #[error("source payload is missing for addon {0}")] + PayloadMissing(Uuid), /// Local storage contains more than one addon with the same immutable identifier. #[error("local storage contains duplicate addon {0}")] Duplicate(Uuid), @@ -37,10 +40,10 @@ pub enum AddonError { /// A component directory lacks the marker required by its slot. #[error("component could not be identified: {0}")] InvalidComponent(PathBuf), - /// A local addon index contains inconsistent or unsafe metadata. - #[error("addon index is invalid: {0}")] - InvalidAddonIndex(PathBuf), - /// A component download would overwrite an existing version directory. + /// A release record contains inconsistent or unsafe metadata. + #[error("release record is invalid: {0}")] + InvalidRelease(PathBuf), + /// A component download would overwrite an existing release directory. #[error("addon target already exists: {0}")] TargetExists(PathBuf), } diff --git a/src/addons/index/mod.rs b/src/addons/index/mod.rs deleted file mode 100644 index 92d94b0..0000000 --- a/src/addons/index/mod.rs +++ /dev/null @@ -1,287 +0,0 @@ -//! Local addon indexes and hand-placed component discovery. -//! -//! Components live at `components///`; dependencies live at -//! `dependencies//`. -//! -//! Component directories are discoverable, so rebuilding adds hand-placed -//! components and removes entries whose directories disappeared. Dependency -//! identity and recipes cannot be reconstructed from downloaded files; their -//! index is therefore authoritative. Catalogs remain separate JSON documents -//! and are never serialized into an index. - -use std::{collections::HashMap, path::PathBuf, sync::Arc}; - -use next_config::Config; -use serde::{Deserialize, Serialize}; -use uuid::{NonNilUuid, Uuid}; - -use super::{ - Addon, AddonError, Component, Dependency, Requirement, Slot, - catalog::{AddonFamily, Catalog}, - installer::Artifact, -}; -use crate::{Directories, error::Result}; - -mod rebuild; - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde( - deny_unknown_fields, - bound(serialize = "K: Serialize", deserialize = "K: Deserialize<'de>") -)] -/// A snapshot of the catalog and indexed local addons for one category. -/// -/// Only `addons` is persisted. UUID keys must match their addon records; -/// release locations are derived from their typed identities. -pub(crate) struct AddonIndex { - /// The last usable catalog, retained only for the current process snapshot. - #[serde(skip)] - pub(crate) catalog: Option>>, - /// Complete local releases keyed by immutable release UUID. - #[serde(rename = "addon")] - pub(crate) addons: HashMap>>, -} - -impl Config for AddonIndex { - const VERSION: u32 = 1; -} - -impl Config for AddonIndex { - const VERSION: u32 = 1; -} - -impl AddonIndex { - /// Loads the persisted index without examining addon directories. - /// - /// A missing file produces an empty index. An existing non-file or malformed - /// index is an error because silently rebuilding it could discard catalog - /// UUIDs and installation recipes. - async fn open(directories: &Directories) -> Result - where - K: AddonFamily, - Self: Config, - { - let path = K::index(directories); - match async_fs::metadata(&path).await { - Ok(metadata) if metadata.is_file() => Ok(next_config::load(path).await?), - Ok(_) => Err(AddonError::InvalidAddonIndex(path).into()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self { - catalog: None, - addons: HashMap::new(), - }), - Err(error) => Err(error.into()), - } - } - - /// Attaches the current catalog without including it in persisted index data. - pub(crate) fn with_catalog(mut self, catalog: Arc>) -> Self { - self.catalog = Some(catalog); - self - } - - /// Atomically replaces the category index while omitting the runtime catalog. - pub(crate) async fn save(&self, directories: &Directories) -> Result<()> - where - K: AddonFamily, - Self: Config, - { - next_config::save(K::index(directories), self).await?; - Ok(()) - } -} - -/// A downloaded or hand-placed addon recorded in shared storage. -/// -/// `K` is either [`Component`] or [`Dependency`]. Values do not update after -/// downloads, removals, or catalog refreshes; query [`crate::Addons`] again to -/// observe later state. Dependency entries retain installation artifacts; -/// converting an entry to [`Addon`] produces the artifact-free value suitable -/// for bottle state. Local paths are derived from the active Bottles data directory. -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(bound(serialize = "K: Serialize", deserialize = "K: Deserialize<'de>"))] -pub struct IndexEntry { - #[serde(flatten)] - addon: Addon, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - artifacts: Vec, -} - -impl IndexEntry { - fn new(addon: Addon, artifacts: Vec) -> Self { - Self { addon, artifacts } - } - - /// Returns the artifact-free selection metadata suitable for bottle state. - pub fn addon(&self) -> &Addon { - &self.addon - } - - /// Returns the release identifier shared by its catalog and bottle records. - pub fn id(&self) -> Uuid { - self.addon.id() - } - - /// Returns the catalog label, or version directory name for hand-placed components. - pub fn name(&self) -> &str { - self.addon.name() - } - - /// Returns the downloaded catalog or hand-placed version string. - pub fn version(&self) -> &str { - self.addon.version() - } - - /// Returns the addons that must coexist with this release. - pub fn requirements(&self) -> &[Requirement] { - self.addon.requirements() - } -} - -impl From<&IndexEntry> for Addon { - fn from(entry: &IndexEntry) -> Self { - entry.addon.clone() - } -} - -impl IndexEntry { - pub(crate) fn new_component( - id: NonNilUuid, - name: String, - version: String, - slot: Slot, - requirements: Vec, - ) -> Self { - Self::new( - Addon::new(id, name, version, requirements, Component { slot }), - Vec::new(), - ) - } - - /// Returns the mutually exclusive role occupied by this component. - pub fn slot(&self) -> Slot { - self.addon.slot() - } - - pub(crate) fn path(&self, directories: &Directories) -> PathBuf { - self.addon.path(directories) - } - - #[cfg(feature = "fvs")] - pub(crate) fn artifact(&self, directories: &Directories) -> Artifact { - self.addon.artifact(directories) - } -} - -impl IndexEntry { - pub(crate) fn new_dependency( - id: NonNilUuid, - name: String, - version: String, - requirements: Vec, - artifacts: Vec, - ) -> Self { - Self::new( - Addon::new(id, name, version, requirements, Dependency::default()), - artifacts, - ) - } - - /// Resolves dependency recipe resources against their downloaded directory. - pub(crate) fn resources(&self, directories: &Directories) -> Vec { - let root = self.path(directories); - self.artifacts - .iter() - .map(|artifact| Artifact::new(root.join(&artifact.path), artifact.steps.clone())) - .collect() - } - - pub(crate) fn artifacts(&self) -> &[Artifact] { - &self.artifacts - } - - pub(crate) fn path(&self, directories: &Directories) -> PathBuf { - directories.dependencies().join(self.id().to_string()) - } -} - -#[cfg(test)] -mod tests { - use serde_json::Value; - - use super::*; - - fn id() -> NonNilUuid { - NonNilUuid::new(Uuid::new_v4()).unwrap() - } - - #[test] - fn index_entry_flattens_addon_and_rejects_unknown_fields() { - let entry = IndexEntry::new_dependency( - id(), - "dependency".into(), - "1.0.0".into(), - vec![Requirement::Slot(Slot::Runner)], - vec![Artifact::new(PathBuf::from("setup.exe"), Vec::new())], - ); - let value = serde_json::to_value(&entry).unwrap(); - - assert!(value.get("addon").is_none()); - assert_eq!(value["name"], "dependency"); - assert_eq!(value["artifacts"][0]["path"], "setup.exe"); - assert_eq!( - serde_json::from_value::>(value.clone()).unwrap(), - entry - ); - - let addon = Addon::from(&entry); - let addon_value = serde_json::to_value(&addon).unwrap(); - assert!(addon_value.get("artifacts").is_none()); - assert_eq!(addon.id(), entry.id()); - assert_eq!(addon.requirements(), entry.requirements()); - - let mut unknown_entry = value; - unknown_entry - .as_object_mut() - .unwrap() - .insert("unknown".into(), Value::Bool(true)); - assert!(serde_json::from_value::>(unknown_entry).is_err()); - - let mut unknown_addon = addon_value; - unknown_addon - .as_object_mut() - .unwrap() - .insert("unknown".into(), Value::Bool(true)); - assert!(serde_json::from_value::>(unknown_addon).is_err()); - } - - #[test] - fn index_paths_use_active_directories() { - let root = std::env::temp_dir().join(format!("bottles-next-{}", Uuid::new_v4())); - let directories = Directories::from_path(&root).unwrap(); - let component = IndexEntry::new_component( - id(), - "runner".into(), - "1.0.0".into(), - Slot::Runner, - Vec::new(), - ); - let dependency = IndexEntry::new_dependency( - id(), - "dependency".into(), - "1.0.0".into(), - Vec::new(), - vec![Artifact::new(PathBuf::from("setup.exe"), Vec::new())], - ); - - assert_eq!( - component.path(&directories), - directories.components().join("runner/1.0.0") - ); - assert_eq!( - dependency.path(&directories), - directories.dependencies().join(dependency.id().to_string()) - ); - let serialized = serde_json::to_string(&(component, dependency)).unwrap(); - assert!(!serialized.contains(root.to_string_lossy().as_ref())); - } -} diff --git a/src/addons/index/rebuild.rs b/src/addons/index/rebuild.rs deleted file mode 100644 index 128acc8..0000000 --- a/src/addons/index/rebuild.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! Reconciles persisted addon indexes with managed storage. -//! -//! Component releases can be identified from their slot directory and contents, -//! so their index is rebuilt from disk. Dependency identities and recipes cannot -//! be reconstructed; rebuilding that family only validates persisted metadata. - -use std::{ - collections::HashMap, - path::{Component as PathComponent, Path, PathBuf}, - sync::Arc, -}; - -use futures_lite::StreamExt; -use strum::IntoEnumIterator; -use uuid::{NonNilUuid, Uuid}; - -use crate::{ - Directories, - error::Result, - runner::{RunnerKind, detect_runner_kind}, -}; - -use super::super::{AddonError, Component, Dependency, Requirement, Slot, catalog::AddonFamily}; -use super::{AddonIndex, IndexEntry}; - -impl AddonIndex { - /// Loads and rebuilds the component index, persisting it only when changed. - pub(crate) async fn load(directories: &Directories) -> Result { - let mut index = Self::open(directories).await?; - let previous = index.addons.clone(); - index.rebuild(directories).await?; - if index.addons != previous { - index.save(directories).await?; - } - Ok(index) - } - - /// Reconciles indexed records with component directories. - /// - /// Matching slot/version records keep their catalog UUID. New directories - /// receive a deterministic path-derived UUID, and records missing from disk - /// are dropped. A retained record is rejected when its derived requirements - /// have changed, because silently keeping its catalog identity would attach - /// that identity to different contents. - async fn rebuild(&mut self, directories: &Directories) -> Result<()> { - let index_path = Component::index(directories); - let root = async_fs::canonicalize(directories.components()).await?; - let mut indexed = HashMap::new(); - for (id, addon) in &self.addons { - if *id != addon.id() { - return Err(AddonError::InvalidAddonIndex(index_path).into()); - } - if indexed - .insert((addon.slot(), addon.version().to_owned()), addon.clone()) - .is_some() - { - return Err(AddonError::InvalidAddonIndex(index_path).into()); - } - } - - let mut addons = HashMap::new(); - for slot in Slot::iter() { - let slot_root = root.join(slot.as_str()); - let mut versions = match async_fs::read_dir(slot_root).await { - Ok(versions) => versions, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, - Err(error) => return Err(error.into()), - }; - while let Some(version) = versions.try_next().await? { - if !version.file_type().await?.is_dir() { - continue; - } - let path = async_fs::canonicalize(version.path()).await?; - let version = version.file_name().to_string_lossy().into_owned(); - if slot != Slot::Runner && semver::Version::parse(&version).is_err() { - return Err(AddonError::InvalidComponent(path).into()); - } - - let requirements = Self::inspect_release(slot, &path).await?; - let addon = if let Some(addon) = indexed.remove(&(slot, version.clone())) { - if addon.requirements() != requirements { - return Err(AddonError::InvalidAddonIndex(index_path).into()); - } - addon - } else { - let id = - Uuid::new_v5(&Uuid::NAMESPACE_URL, path.as_os_str().as_encoded_bytes()); - Arc::new(IndexEntry::new_component( - NonNilUuid::new(id).expect("v5 UUID is non-nil"), - version.clone(), - version, - slot, - requirements, - )) - }; - if addons.insert(addon.id(), addon.clone()).is_some() { - return Err(AddonError::Duplicate(addon.id()).into()); - } - } - } - self.addons = addons; - Ok(()) - } - - pub(crate) async fn target( - directories: &Directories, - slot: Slot, - version: &str, - ) -> Result { - let slot_root = directories.components().join(slot.as_str()); - async_fs::create_dir_all(&slot_root).await?; - Ok(async_fs::canonicalize(slot_root).await?.join(version)) - } - - /// Validates the files that identify `slot` and derives requirements from them. - pub(crate) async fn inspect_release(slot: Slot, path: &Path) -> Result> { - let invalid = || AddonError::InvalidComponent(path.to_path_buf()); - match slot { - Slot::Runner => Ok(match detect_runner_kind(path).await? { - RunnerKind::Wine => Vec::new(), - RunnerKind::Proton => vec![Requirement::Slot(Slot::Umu)], - }), - Slot::WineBridge => { - if !regular_file(&path.join("bottles-winebridge.exe")).await { - return Err(invalid().into()); - } - Ok(Vec::new()) - } - Slot::Umu => { - if !regular_file(&path.join("umu-run")).await { - return Err(invalid().into()); - } - Ok(Vec::new()) - } - Slot::Nvapi => Ok(vec![Requirement::Slot(Slot::Dxvk)]), - Slot::Dxvk | Slot::Vkd3d | Slot::LatencyFlex => Ok(Vec::new()), - } - } -} - -impl AddonIndex { - /// Loads the dependency index and validates its persisted record structure. - /// - /// This does not discover dependencies or verify artifact files on disk; - /// neither dependency identity nor installation recipes can be reconstructed - /// from storage alone. - pub(crate) async fn load(directories: &Directories) -> Result { - let mut index = Self::open(directories).await?; - let previous = index.addons.clone(); - index.rebuild(directories).await?; - if index.addons != previous { - index.save(directories).await?; - } - Ok(index) - } - - /// Rejects records with mismatched UUIDs, no artifacts, or unsafe artifact paths. - async fn rebuild(&mut self, directories: &Directories) -> Result<()> { - let index_path = Dependency::index(directories); - let mut addons = HashMap::new(); - for (id, addon) in &self.addons { - if *id != addon.id() - || addon.artifacts().is_empty() - || addon - .artifacts() - .iter() - .any(|artifact| !single_path_component(&artifact.path)) - { - return Err(AddonError::InvalidAddonIndex(index_path).into()); - } - addons.insert(*id, addon.clone()); - } - self.addons = addons; - Ok(()) - } - - pub(crate) async fn target(directories: &Directories, id: Uuid) -> Result { - let path = directories.dependencies(); - async_fs::create_dir_all(&path).await?; - Ok(async_fs::canonicalize(path).await?.join(id.to_string())) - } -} - -fn single_path_component(value: impl AsRef) -> bool { - let mut components = value.as_ref().components(); - matches!(components.next(), Some(PathComponent::Normal(_))) && components.next().is_none() -} - -async fn regular_file(path: &Path) -> bool { - async_fs::metadata(path) - .await - .is_ok_and(|entry| entry.is_file()) -} diff --git a/src/addons/installer/engine.rs b/src/addons/installer/engine.rs index 865cc4a..bc0f74b 100644 --- a/src/addons/installer/engine.rs +++ b/src/addons/installer/engine.rs @@ -16,7 +16,7 @@ use crate::{ winebridge::WineBridgeClient, }; -use super::{Artifact, InstallInputs, InstallStep}; +use super::{InstallInputs, InstallResource, InstallStep}; /// Applies every resource and step sequentially, reporting each step before it starts. /// @@ -26,7 +26,8 @@ use super::{Artifact, InstallInputs, InstallStep}; /// before diffing, unmounting, or restoring storage. pub(crate) async fn execute( inputs: InstallInputs<'_>, - resources: &[Artifact], + payload_root: &Path, + resources: &[InstallResource], cancellation: &CancellationToken, on_step: impl Fn(&InstallStep) + Send, ) -> Result<()> { @@ -39,6 +40,7 @@ pub(crate) async fn execute( } = inputs; check_cancellation(cancellation)?; for resource in resources { + let source = payload_root.join(&resource.path); for step in &resource.steps { on_step(step); execute_step( @@ -49,7 +51,7 @@ pub(crate) async fn execute( env_vars: &mut *env_vars, explicit_env_vars, }, - resource, + &source, step, cancellation, ) @@ -66,9 +68,9 @@ pub(crate) async fn execute( /// removed and DLL overrides are deleted. Other step kinds have no inverse and are skipped with a /// warning. File, bridge and override failures are logged and ignored; cancellation is returned. /// The enclosing prefix scope owns Wine shutdown. -pub(crate) async fn uninstall( +pub(crate) async fn uninstall<'a>( inputs: InstallInputs<'_>, - resources: &[Artifact], + steps: impl DoubleEndedIterator, item_id: Uuid, cancellation: &CancellationToken, on_step: impl Fn(&InstallStep) + Send, @@ -82,24 +84,22 @@ pub(crate) async fn uninstall( } = inputs; check_cancellation(cancellation)?; - for resource in resources.iter().rev() { - for step in resource.steps.iter().rev() { - on_step(step); - uninstall_step( - InstallInputs { - prefix, - runner, - winebridge, - env_vars: &mut *env_vars, - explicit_env_vars, - }, - step, - item_id, - cancellation, - ) - .await?; - check_cancellation(cancellation)?; - } + for step in steps.rev() { + on_step(step); + uninstall_step( + InstallInputs { + prefix, + runner, + winebridge, + env_vars: &mut *env_vars, + explicit_env_vars, + }, + step, + item_id, + cancellation, + ) + .await?; + check_cancellation(cancellation)?; } Ok(()) } @@ -134,7 +134,7 @@ async fn maintenance_bridge( async fn execute_step( inputs: InstallInputs<'_>, - resource: &Artifact, + resource: &Path, step: &InstallStep, cancellation: &CancellationToken, ) -> Result<()> { @@ -151,17 +151,17 @@ async fn execute_step( destination, } => { let source = if source.as_os_str().is_empty() { - resource.path.clone() + resource.to_path_buf() } else { - resource.path.join(source) + resource.join(source) }; install_file(&source, prefix, destination).await?; } InstallStep::Extract { destination } => { - extract_into(&resource.path, prefix, destination, cancellation).await?; + extract_into(resource, prefix, destination, cancellation).await?; } InstallStep::Execute { arguments } => { - let mut command = Command::new(&resource.path); + let mut command = Command::new(resource); for argument in arguments { command = command.arg(argument); } diff --git a/src/addons/installer/mod.rs b/src/addons/installer/mod.rs index 92ebc2f..baf164a 100644 --- a/src/addons/installer/mod.rs +++ b/src/addons/installer/mod.rs @@ -1,9 +1,7 @@ //! Addon installation recipes and their executor. //! -//! Downloaded dependency artifacts retain their catalog recipes in the local -//! index. Components instead derive a built-in recipe from their [`super::Slot`], -//! allowing a bottle to remove a selected component without consulting the -//! catalog or local index. +//! Each local release stores its installation recipes together with its source payload. +//! Built-in recipes supply component defaults during import and download. //! //! # Installation //! @@ -43,31 +41,31 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::{ - Directories, proto::{DllOverrideMode, RegistryHive, registry_value::Value as RegistryValue}, runner::Runner, utils::env_vars::EnvVars, }; -use super::{Addon, Component, deserialize_non_empty_string}; +use super::deserialize_non_empty_string; pub(crate) use engine::{execute, replay_env_vars, uninstall}; pub(crate) use recipes::steps as recipe_steps; -/// One local resource and the installation steps applied to it. -/// -/// Persisted dependency index entries store a single-component relative path. -/// Bottle installation resolves that path before passing the resource to the -/// engine. Component resources are derived directly from their slot and version. +/// A local installation resource and its frozen recipe. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub(crate) struct Artifact { +#[serde(deny_unknown_fields)] +pub(crate) struct InstallResource { + /// Relative to the release payload; empty for a component's payload directory. pub(crate) path: PathBuf, pub(crate) steps: Vec, } -impl Artifact { - pub(crate) fn new(path: PathBuf, steps: Vec) -> Self { - Self { path, steps } +impl InstallResource { + pub(crate) fn new(path: impl Into, steps: Vec) -> Self { + Self { + path: path.into(), + steps, + } } } @@ -158,10 +156,3 @@ pub(crate) struct InstallInputs<'a> { /// Explicit owner settings override recipe contributions for every process. pub(crate) explicit_env_vars: &'a EnvVars, } - -impl Addon { - /// Derives the component resource and built-in recipe from stored metadata. - pub(crate) fn artifact(&self, directories: &Directories) -> Artifact { - Artifact::new(self.path(directories), recipe_steps(self.slot()).to_vec()) - } -} diff --git a/src/addons/installer/recipes.rs b/src/addons/installer/recipes.rs index f5ae308..4278e3d 100644 --- a/src/addons/installer/recipes.rs +++ b/src/addons/installer/recipes.rs @@ -1,8 +1,5 @@ -//! Built-in installation recipes for recognized addon slots. -//! -//! These recipes are implementation details, not stable step-by-step contracts. -//! Every selected component derives its removal and installation recipe from its -//! slot, keeping bottle state independent of catalogs and downloaded index data. +//! Default component recipes for imports and catalog entries without recipe overrides. +//! Acquisition freezes the resolved recipe into the release; execution uses that record. use std::sync::LazyLock; @@ -152,7 +149,7 @@ static LATENCY_FLEX_STEPS: LazyLock> = LazyLock::new(|| { ] }); -/// Returns the built-in recipe for a component slot; runtime slots need no prefix changes. +/// Returns the default recipe for a component slot; runtime slots need no prefix changes. pub(crate) fn steps(slot: Slot) -> &'static [InstallStep] { match slot { Slot::WineBridge | Slot::Runner | Slot::Umu => &[], diff --git a/src/addons/manager/catalog.rs b/src/addons/manager/catalog.rs index 6c1f834..14e727e 100644 --- a/src/addons/manager/catalog.rs +++ b/src/addons/manager/catalog.rs @@ -57,18 +57,19 @@ impl Addons { catalog.save(addons.0.context.directories()).await?; Some(catalog.clone()) } - Err(_) => current.components.catalog.clone(), + Err(_) => current.component_catalog.clone(), }; let dependency_catalog = match &dependency { Ok(catalog) => { catalog.save(addons.0.context.directories()).await?; Some(catalog.clone()) } - Err(_) => current.dependencies.catalog.clone(), + Err(_) => current.dependency_catalog.clone(), }; - addons - .publish(component_catalog, dependency_catalog) - .await?; + let mut next = current.as_ref().clone(); + next.component_catalog = component_catalog; + next.dependency_catalog = dependency_catalog; + addons.publish(next); match (component, dependency) { (Ok(_), Ok(_)) => Ok(()), diff --git a/src/addons/manager/fetch.rs b/src/addons/manager/fetch.rs index 6e74bcb..d6a15d9 100644 --- a/src/addons/manager/fetch.rs +++ b/src/addons/manager/fetch.rs @@ -1,100 +1,106 @@ -//! Download, validation, and publication of catalog releases. +//! Fetch and publish complete immutable releases. -use std::{ - path::{Component as PathComponent, Path, PathBuf}, - sync::Arc, +use super::super::{ + AddonError, CatalogError, Component, Dependency, Release, + catalog::{CatalogArtifact, Target}, + installer::{InstallResource, recipe_steps}, }; - -use download_manager::manager::DownloadManager; -use futures_lite::StreamExt; -use tokio::sync::watch; -use tokio_util::sync::CancellationToken; -use uuid::{NonNilUuid, Uuid}; - +use super::{Addons, download, prepare_component_archive}; use crate::{ Operation, Progress, Stage, error::{Error, Result}, - utils::{archive, checksum, exists}, -}; - -use super::super::{ - AddonError, CatalogError, Component, Dependency, IndexEntry, - catalog::{CatalogArtifact, Target}, - index::AddonIndex, - installer::Artifact, + utils::checksum, }; -use super::{Addons, download}; +use download_manager::manager::DownloadManager; +use std::{path::Path, sync::Arc}; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; +use uuid::{NonNilUuid, Uuid}; impl Addons { - /// Downloads and publishes a component from the current catalog. - /// - /// If the catalog still contains `id` and that release is already indexed, - /// the operation returns the existing entry without downloading it again. - /// Otherwise, exactly one artifact must match the current platform. Its - /// checksum, archive shape, slot-specific files, and storage paths are - /// validated before the release is moved into shared storage and published. - /// Fetching does not select the component in any bottle. - /// - /// Downloads and extraction occur outside the manager's write lock. The - /// operation rechecks the index before committing, so concurrent fetches of - /// the same release converge on the first published entry. Staging cleanup - /// and rollback after a failed commit are best effort. - /// - /// # Errors - /// - /// The operation returns [`CatalogError::NotFound`] if `id` is absent from - /// the current catalog, [`CatalogError::Unsupported`] if no artifact matches, - /// or [`CatalogError::InvalidComponentArtifactCount`] if more than one - /// matches. Invalid paths, checksum or archive failures, an occupied target, - /// I/O and persistence failures, and cancellation are also returned. - pub fn fetch_component(&self, id: Uuid) -> Operation>> { + /// Returns an existing local component or downloads the selected catalog release. + /// Catalog refreshes never replace a local release's metadata or recipe. + pub fn fetch_component(&self, id: Uuid) -> Operation>> { let addons = self.clone(); Operation::new(move |progress, cancellation| async move { + { + let _write = cancellation + .run_until_cancelled(addons.0.write.lock()) + .await + .ok_or(Error::Cancelled)?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + if let Some(release) = addons.component(id) { + release + .require_payload(addons.0.context.directories()) + .await?; + return Ok(release); + } + if addons.state().contains(id) { + return Err(AddonError::Duplicate(id).into()); + } + } let entry = addons .component_entry(id) .ok_or(CatalogError::NotFound(id))?; - if let Some(component) = addons.component(id) { - return Ok(component); - } - let target = Target::current().ok_or(CatalogError::Unsupported(entry.id()))?; - let artifacts = entry.artifacts_for_target(target).collect::>(); + let target = Target::current().ok_or(CatalogError::Unsupported(id))?; + let artifacts: Vec<_> = entry.artifacts_for_target(target).collect(); if artifacts.is_empty() { - return Err(CatalogError::Unsupported(entry.id()).into()); + return Err(CatalogError::Unsupported(id).into()); } if artifacts.len() != 1 { return Err(CatalogError::InvalidComponentArtifactCount { - addon: entry.id(), + addon: id, count: artifacts.len(), } .into()); } let artifact = artifacts[0]; - if !single_path_component(entry.version()) - || !single_path_component(artifact.file_name()) - { - return Err(CatalogError::InvalidEntry(entry.id()).into()); - } - + let record = Arc::new(Release::new_component( + NonNilUuid::new(id).expect("catalog UUID is non-nil"), + entry.name().into(), + entry.version().into(), + entry.slot(), + entry.requirements().to_vec(), + InstallResource::new( + "", + artifact + .steps() + .unwrap_or_else(|| recipe_steps(entry.slot())) + .to_vec(), + ), + )); + record.validate(&record.directory(addons.0.context.directories()))?; let stage = addons.create_stage().await?; let result = async { - let file = stage.join(artifact.file_name()); + let downloads = stage.join("downloads"); + async_fs::create_dir(&downloads).await?; + let file = downloads.join(artifact.file_name()); download_artifact( addons.0.context.downloader(), artifact, &file, - progress, + &progress, &cancellation, ) .await?; - let extracted = stage.join("extracted"); - async_fs::create_dir_all(&extracted).await?; - cancellation - .run_until_cancelled(archive::extract(&file, &extracted)) + let prepared = prepare_component_archive(&file, &stage, &cancellation).await?; + addons + .commit_component(record, &prepared, &cancellation) .await - .ok_or(Error::Cancelled)??; - let release = top_level_directory(&extracted).await?; - let slot = entry.slot(); - let requirements = AddonIndex::::inspect_release(slot, &release).await?; + } + .await; + let _ = async_fs::remove_dir_all(stage).await; + result + }) + } + + /// Returns an existing local dependency or downloads the selected catalog release. + pub fn fetch_dependency(&self, id: Uuid) -> Operation>> { + let addons = self.clone(); + Operation::new(move |progress, cancellation| async move { + { let _write = cancellation .run_until_cancelled(addons.0.write.lock()) .await @@ -102,171 +108,55 @@ impl Addons { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - if let Some(component) = addons.component(entry.id()) { - return Ok(component); - } - let state = addons.state(); - let target = AddonIndex::::target( - addons.0.context.directories(), - slot, - entry.version(), - ) - .await?; - if exists(&target).await? { - return Err(AddonError::TargetExists(target).into()); - } - let component = IndexEntry::new_component( - NonNilUuid::new(entry.id()).expect("catalog UUID is non-nil"), - entry.name().to_owned(), - entry.version().to_owned(), - slot, - requirements, - ); - let mut next = state.components.clone(); - next.addons.insert(component.id(), Arc::new(component)); - next.save(addons.0.context.directories()).await?; - if let Err(error) = async_fs::rename(release, &target).await { - let _ = state.components.save(addons.0.context.directories()).await; - return Err(error.into()); + if let Some(release) = addons.dependency(id) { + release + .require_payload(addons.0.context.directories()) + .await?; + return Ok(release); } - let published = addons - .publish( - state.components.catalog.clone(), - state.dependencies.catalog.clone(), - ) - .await - .and_then(|_| { - addons - .component(entry.id()) - .ok_or_else(|| AddonError::NotFound(entry.id()).into()) - }); - if published.is_err() { - let _ = async_fs::remove_dir_all(target).await; - let _ = state.components.save(addons.0.context.directories()).await; + if addons.state().contains(id) { + return Err(AddonError::Duplicate(id).into()); } - published } - .await; - let _ = async_fs::remove_dir_all(stage).await; - result - }) - } - - /// Downloads and publishes a dependency from the current catalog. - /// - /// If the catalog still contains `id` and that release is already indexed, - /// the operation returns the existing entry without downloading it again. - /// Otherwise, every artifact matching the current platform is downloaded and - /// checksum-verified. Their catalog recipes are retained in the index for - /// later bottle installation. Fetching does not install the dependency into - /// any bottle. - /// - /// Downloads occur outside the manager's write lock. The operation rechecks - /// the index before committing, so concurrent fetches of the same release - /// converge on the first published entry. Staging cleanup and rollback after - /// a failed commit are best effort. - /// - /// # Errors - /// - /// The operation returns [`CatalogError::NotFound`] if `id` is absent from - /// the current catalog or [`CatalogError::Unsupported`] if no artifact - /// matches. Invalid paths, checksum failures, I/O and persistence failures, - /// and cancellation are also returned. - pub fn fetch_dependency(&self, id: Uuid) -> Operation>> { - let addons = self.clone(); - Operation::new(move |progress, cancellation| async move { let entry = addons .dependency_entry(id) .ok_or(CatalogError::NotFound(id))?; - if let Some(dependency) = addons.dependency(id) { - return Ok(dependency); - } - let target = Target::current().ok_or(CatalogError::Unsupported(entry.id()))?; - let artifacts = entry.artifacts_for_target(target).collect::>(); + let target = Target::current().ok_or(CatalogError::Unsupported(id))?; + let artifacts: Vec<_> = entry.artifacts_for_target(target).collect(); if artifacts.is_empty() { - return Err(CatalogError::Unsupported(entry.id()).into()); - } - if artifacts - .iter() - .any(|artifact| !single_path_component(artifact.file_name())) - { - return Err(CatalogError::InvalidEntry(entry.id()).into()); + return Err(CatalogError::Unsupported(id).into()); } - + let record = Arc::new(Release::new_dependency( + NonNilUuid::new(id).expect("catalog UUID is non-nil"), + entry.name().into(), + entry.version().into(), + entry.requirements().to_vec(), + artifacts + .iter() + .map(|a| { + InstallResource::new(a.file_name(), a.steps().unwrap_or_default().to_vec()) + }) + .collect(), + )); + record.validate(&record.directory(addons.0.context.directories()))?; let stage = addons.create_stage().await?; let result = async { - for artifact in artifacts.iter().copied() { + let prepared = stage.join("release"); + let payload = prepared.join("payload"); + async_fs::create_dir_all(&payload).await?; + for artifact in &artifacts { download_artifact( addons.0.context.downloader(), artifact, - &stage.join(artifact.file_name()), - progress.clone(), + &payload.join(artifact.file_name()), + &progress, &cancellation, ) .await?; } - - let _write = cancellation - .run_until_cancelled(addons.0.write.lock()) + addons + .commit_dependency(record, &prepared, &cancellation) .await - .ok_or(Error::Cancelled)?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - if let Some(dependency) = addons.dependency(entry.id()) { - return Ok(dependency); - } - let state = addons.state(); - let target = - AddonIndex::::target(addons.0.context.directories(), entry.id()) - .await?; - if exists(&target).await? { - async_fs::remove_dir_all(&target).await?; - } - let dependency = IndexEntry::new_dependency( - NonNilUuid::new(entry.id()).expect("catalog UUID is non-nil"), - entry.name().to_owned(), - entry.version().to_owned(), - entry.requirements().to_vec(), - artifacts - .iter() - .map(|artifact| { - Artifact::new( - PathBuf::from(artifact.file_name()), - artifact.steps().to_vec(), - ) - }) - .collect(), - ); - let mut next = state.dependencies.clone(); - next.addons.insert(dependency.id(), Arc::new(dependency)); - next.save(addons.0.context.directories()).await?; - if let Err(error) = async_fs::rename(&stage, &target).await { - let _ = state - .dependencies - .save(addons.0.context.directories()) - .await; - return Err(error.into()); - } - let published = addons - .publish( - state.components.catalog.clone(), - state.dependencies.catalog.clone(), - ) - .await - .and_then(|_| { - addons - .dependency(entry.id()) - .ok_or_else(|| AddonError::NotFound(entry.id()).into()) - }); - if published.is_err() { - let _ = async_fs::remove_dir_all(target).await; - let _ = state - .dependencies - .save(addons.0.context.directories()) - .await; - } - published } .await; let _ = async_fs::remove_dir_all(stage).await; @@ -275,18 +165,11 @@ impl Addons { } } -/// Restricts catalog-controlled names to one normal path component. -fn single_path_component(value: &str) -> bool { - let mut components = Path::new(value).components(); - matches!(components.next(), Some(PathComponent::Normal(_))) && components.next().is_none() -} - -/// Downloads one artifact and verifies its checksum before it can be committed. async fn download_artifact( downloader: &DownloadManager, artifact: &CatalogArtifact, destination: &Path, - progress: watch::Sender>, + progress: &watch::Sender>, cancellation: &CancellationToken, ) -> Result<()> { download( @@ -297,7 +180,7 @@ async fn download_artifact( |transfer| { progress.send_replace(Some(Progress::transferring( Stage::Downloading { - file: artifact.file_name().to_owned(), + file: artifact.file_name().into(), }, transfer, ))); @@ -305,25 +188,13 @@ async fn download_artifact( ) .await?; progress.send_replace(Some(Progress::new(Stage::Verifying { - file: artifact.file_name().to_owned(), + file: artifact.file_name().into(), }))); - if !checksum::verify(destination, artifact.checksum()).await? { - return Err(AddonError::ChecksumMismatch(destination.to_path_buf()).into()); - } if cancellation.is_cancelled() { return Err(Error::Cancelled); } - Ok(()) -} - -/// Requires a component archive to contain exactly one top-level directory. -async fn top_level_directory(root: &Path) -> Result { - let mut entries = async_fs::read_dir(root).await?; - let Some(entry) = entries.next().await.transpose()? else { - return Err(AddonError::InvalidComponentArchive.into()); - }; - if entries.next().await.transpose()?.is_some() || !entry.file_type().await?.is_dir() { - return Err(AddonError::InvalidComponentArchive.into()); + if !checksum::verify(destination, artifact.checksum()).await? { + return Err(AddonError::ChecksumMismatch(destination.to_path_buf()).into()); } - Ok(entry.path()) + Ok(()) } diff --git a/src/addons/manager/import.rs b/src/addons/manager/import.rs new file mode 100644 index 0000000..8a8fea0 --- /dev/null +++ b/src/addons/manager/import.rs @@ -0,0 +1,76 @@ +//! Explicit local component archive import. Templates are frozen into fresh releases. + +use super::super::{ + AddonError, Component, Release, Requirement, Slot, + installer::{InstallResource, InstallStep, recipe_steps}, +}; +use super::{Addons, prepare_component_archive}; +use crate::{ + Operation, Progress, Stage, + error::Result, + runner::{RunnerKind, detect_runner_kind}, +}; +use std::{path::Path, sync::Arc}; +use uuid::{NonNilUuid, Uuid}; + +impl Addons { + /// Imports a local tar, tar.gz/tgz, or tar.xz/txz.Assigns a fresh UUID and freezes the bundled recipe. The source archive is unchanged; directories are not supported. + pub fn import_component( + &self, + path: impl AsRef, + slot: Slot, + name: impl Into, + version: impl Into, + ) -> Operation>> { + let source = path.as_ref().to_path_buf(); + let name = name.into(); + let version = version.into(); + let addons = self.clone(); + Operation::new(move |progress, cancellation| async move { + progress.send_replace(Some(Progress::new(Stage::Preparing))); + let stage = addons.create_stage().await?; + let result = async { + let prepared = prepare_component_archive(&source, &stage, &cancellation).await?; + let payload = prepared.join("payload"); + let requirements = inspect_release(slot, &payload).await?; + let steps = recipe_steps(slot).to_vec(); + // Template layouts must match the imported files. + for step in &steps { + if let InstallStep::Copy { source, .. } = step { + if !async_fs::metadata(payload.join(source)) + .await + .is_ok_and(|m| m.is_file()) + { + return Err(AddonError::InvalidComponent(payload).into()); + } + } + } + let id = Uuid::new_v4(); + let release = Release::new_component( + NonNilUuid::new(id).unwrap(), + name, + version, + slot, + requirements, + InstallResource::new("", steps), + ); + addons + .commit_component(Arc::new(release), &prepared, &cancellation) + .await + } + .await; + let _ = async_fs::remove_dir_all(stage).await; + result + }) + } +} + +async fn inspect_release(slot: Slot, path: &Path) -> Result> { + Ok(match slot { + Slot::Runner if detect_runner_kind(path).await? == RunnerKind::Proton => { + vec![Requirement::Slot(Slot::Umu)] + } + Slot::Nvapi => vec![Requirement::Slot(Slot::Dxvk)], + _ => Vec::new(), + }) +} diff --git a/src/addons/manager/mod.rs b/src/addons/manager/mod.rs index bfcd488..19be2fe 100644 --- a/src/addons/manager/mod.rs +++ b/src/addons/manager/mod.rs @@ -1,13 +1,14 @@ //! Shared addon state, queries, publication, and storage removal. use std::{ + collections::HashMap, path::{Path, PathBuf}, sync::Arc, }; use download_manager::{events::Progress as DownloadProgress, manager::DownloadManager}; use futures_core::Stream; -use futures_util::{FutureExt, StreamExt}; +use futures_util::{FutureExt, StreamExt, TryStreamExt}; use semver::Version; use tokio::sync::{Mutex, watch}; use tokio_stream::wrappers::WatchStream; @@ -16,9 +17,8 @@ use url::Url; use uuid::Uuid; use super::{ - AddonError, Component, Dependency, IndexEntry, Slot, + AddonError, Component, Dependency, Release, Slot, catalog::{Catalog, CatalogEntry, CatalogUrls}, - index::AddonIndex, }; use crate::{ Context, Directories, Transfer, @@ -27,16 +27,17 @@ use crate::{ mod catalog; mod fetch; +mod import; /// The shared manager for addon catalogs and local storage. /// /// Remote releases are exposed as [`CatalogEntry`] values. Fetching one adds an -/// [`IndexEntry`] to shared storage; bottles then persist an artifact-free +/// [`Release`] to shared storage; bottles then persist an artifact-free /// [`Addon`](crate::Addon) when selecting a component or installing a dependency. /// Fetching alone does not modify any bottle. /// /// Clones refer to the same manager state. Returned [`CatalogEntry`] values and -/// [`IndexEntry`] handles are snapshots: they do not change after a refresh, +/// [`Release`] handles are snapshots: they do not change after a refresh, /// fetch, or removal. Query the manager again, or use [`watch`](Self::watch), to /// observe a later publication. #[derive(Clone)] @@ -51,10 +52,10 @@ struct AddonsInner { } impl Addons { - /// Loads cached catalogs and validates the two local indexes. + /// Loads cached catalogs and complete local releases. /// - /// An unavailable or invalid catalog cache is ignored. An invalid index is - /// returned as an error because it carries local identity and recipe data. + /// An unavailable or invalid catalog cache is ignored. Invalid or incomplete + /// releases are returned as errors. pub(crate) async fn load( context: Context, component_catalog_url: Option, @@ -78,8 +79,7 @@ impl Addons { /// The result is empty when no valid component catalog has been loaded. pub fn component_entries(&self) -> Vec> { self.state() - .components - .catalog + .component_catalog .iter() .flat_map(|catalog| catalog.entries().iter().cloned()) .collect() @@ -90,36 +90,33 @@ impl Addons { /// The result is empty when no valid dependency catalog has been loaded. pub fn dependency_entries(&self) -> Vec> { self.state() - .dependencies - .catalog + .dependency_catalog .iter() .flat_map(|catalog| catalog.entries().iter().cloned()) .collect() } - /// Returns indexed downloaded and hand-placed components. + /// Returns downloaded or imported component releases. /// /// The order is unspecified. - pub fn components(&self) -> Vec>> { - self.state().components.addons.values().cloned().collect() + pub fn components(&self) -> Vec>> { + self.state().components.values().cloned().collect() } - /// Returns dependencies recorded in the local index. - /// - /// The order is unspecified. Dependency records cannot be reconstructed or - /// verified from their files alone, so the persisted index is authoritative. - pub fn dependencies(&self) -> Vec>> { - self.state().dependencies.addons.values().cloned().collect() + /// Returns downloaded dependency releases. + /// The order is unspecified. + pub fn dependencies(&self) -> Vec>> { + self.state().dependencies.values().cloned().collect() } - /// Returns the indexed component with this release identifier. - pub fn component(&self, id: Uuid) -> Option>> { - self.state().components.addons.get(&id).cloned() + /// Returns the known component with this release identifier. + pub fn component(&self, id: Uuid) -> Option>> { + self.state().components.get(&id).cloned() } - /// Returns the indexed dependency with this release identifier. - pub fn dependency(&self, id: Uuid) -> Option>> { - self.state().dependencies.addons.get(&id).cloned() + /// Returns the known dependency with this release identifier. + pub fn dependency(&self, id: Uuid) -> Option>> { + self.state().dependencies.get(&id).cloned() } /// Returns the current component catalog entry with this identifier. @@ -128,8 +125,7 @@ impl Addons { /// is absent from it. pub fn component_entry(&self, id: Uuid) -> Option> { self.state() - .components - .catalog + .component_catalog .as_ref() .and_then(|catalog| catalog.entry(id)) .cloned() @@ -141,14 +137,13 @@ impl Addons { /// is absent from it. pub fn dependency_entry(&self, id: Uuid) -> Option> { self.state() - .dependencies - .catalog + .dependency_catalog .as_ref() .and_then(|catalog| catalog.entry(id)) .cloned() } - /// Watches changes to catalogs and local indexes. + /// Watches changes to catalogs and local releases. /// /// The stream yields immediately and may coalesce publications for slow /// consumers. Each value is a live manager handle; query it for current data. @@ -159,80 +154,154 @@ impl Addons { }) } - /// Removes a component from shared storage and the local index. - /// - /// Bottle references are not checked or updated. Existing [`IndexEntry`] - /// handles remain valid metadata snapshots, but their derived path no longer - /// exists after successful removal. Filesystem removal and index persistence - /// are not transactional; an error does not guarantee that the directory was - /// left untouched. - /// - /// # Errors - /// - /// Returns [`AddonError::NotFound`] when `id` is not indexed. Filesystem, - /// index-persistence, and state-reload failures are also returned. + /// Removes a component's entire release directory. Built Virgo artifacts remain. + /// Does not stop environments or change their selections. pub async fn remove_component(&self, id: Uuid) -> Result<()> { - let _write = self.0.write.lock().await; - let state = self.state(); - let component = state - .components - .addons - .get(&id) - .ok_or(AddonError::NotFound(id))?; - async_fs::remove_dir_all(component.path(self.0.context.directories())).await?; - let mut next = state.components.clone(); - next.addons.remove(&id); - next.save(self.0.context.directories()).await?; - self.publish( - state.components.catalog.clone(), - state.dependencies.catalog.clone(), - ) - .await + let stage = { + let _write = self.0.write.lock().await; + let mut next = self.state().as_ref().clone(); + let release = next + .components + .remove(&id) + .ok_or(AddonError::NotFound(id))?; + let stage = self + .withdraw_release(&release.directory(self.0.context.directories())) + .await?; + self.publish(next); + stage + }; + // Cleanup failure leaves only unpublished staging data. + Ok(async_fs::remove_dir_all(stage).await?) } - /// Removes a dependency from shared storage and the local index. - /// - /// Bottle references are not checked or updated. Existing [`IndexEntry`] - /// handles remain valid metadata snapshots, but their derived path no longer - /// exists after successful removal. Filesystem removal and index persistence - /// are not transactional; an error does not guarantee that the directory was - /// left untouched. - /// - /// # Errors - /// - /// Returns [`AddonError::NotFound`] when `id` is not indexed. Filesystem, - /// index-persistence, and state-reload failures are also returned. + /// Removes a dependency's entire release directory. Built Virgo artifacts remain. + /// Does not stop environments or change their selections. pub async fn remove_dependency(&self, id: Uuid) -> Result<()> { - let _write = self.0.write.lock().await; - let state = self.state(); - let dependency = state - .dependencies - .addons - .get(&id) - .ok_or(AddonError::NotFound(id))?; - async_fs::remove_dir_all(dependency.path(self.0.context.directories())).await?; - let mut next = state.dependencies.clone(); - next.addons.remove(&id); - next.save(self.0.context.directories()).await?; - self.publish( - state.components.catalog.clone(), - state.dependencies.catalog.clone(), - ) - .await + let stage = { + let _write = self.0.write.lock().await; + let mut next = self.state().as_ref().clone(); + let release = next + .dependencies + .remove(&id) + .ok_or(AddonError::NotFound(id))?; + let stage = self + .withdraw_release(&release.directory(self.0.context.directories())) + .await?; + self.publish(next); + stage + }; + // Cleanup failure leaves only unpublished staging data. + Ok(async_fs::remove_dir_all(stage).await?) } - /// Selects the greatest semantic version currently indexed for `slot`. - pub(crate) fn latest_component(&self, slot: Slot) -> Option>> { - self.state() + // Caller holds the manager write lock until the new snapshot is published. + async fn withdraw_release(&self, path: &Path) -> Result { + let stage = self.create_stage().await?; + match async_fs::rename(path, stage.join("release")).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => { + let _ = async_fs::remove_dir_all(&stage).await; + return Err(e.into()); + } + } + Ok(stage) + } + + /// Selects the greatest semantic version among local releases for this slot. + pub(crate) fn latest_component(&self, slot: Slot) -> Option>> { + let state = self.state(); + state .components - .addons .values() - .filter(|component| component.slot() == slot) - .max_by_key(|component| { - Version::parse(component.version()) - .expect("selected component versions are semantic") - }) - .cloned() + .filter(|r| r.slot() == slot) + .filter_map(|r| Version::parse(r.version()).ok().map(|v| (v, r.id(), r))) + .max_by(|a, b| (&a.0, a.1).cmp(&(&b.0, b.1))) + .map(|(_, _, r)| r.clone()) + } + + async fn commit_component( + &self, + record: Arc>, + prepared: &Path, + cancellation: &CancellationToken, + ) -> Result>> { + let id = record.id(); + let destination = record.directory(self.0.context.directories()); + record.validate(&destination)?; + let _write = cancellation + .run_until_cancelled(self.0.write.lock()) + .await + .ok_or(Error::Cancelled)?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let mut next = self.state().as_ref().clone(); + if let Some(current) = next.components.get(&id) { + if current != &record { + return Err(AddonError::InvalidRelease(destination).into()); + } + current + .require_payload(self.0.context.directories()) + .await?; + return Ok(current.clone()); + } + if next.contains(id) { + return Err(AddonError::Duplicate(id).into()); + } + if crate::utils::exists(&destination).await? { + return Err(AddonError::TargetExists(destination).into()); + } + next_config::save(prepared.join("release.toml"), record.as_ref()).await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + async_fs::rename(prepared, destination).await?; + next.components.insert(id, record.clone()); + self.publish(next); + Ok(record) + } + + async fn commit_dependency( + &self, + record: Arc>, + prepared: &Path, + cancellation: &CancellationToken, + ) -> Result>> { + let id = record.id(); + let destination = record.directory(self.0.context.directories()); + record.validate(&destination)?; + let _write = cancellation + .run_until_cancelled(self.0.write.lock()) + .await + .ok_or(Error::Cancelled)?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let mut next = self.state().as_ref().clone(); + if let Some(current) = next.dependencies.get(&id) { + if current != &record { + return Err(AddonError::InvalidRelease(destination).into()); + } + current + .require_payload(self.0.context.directories()) + .await?; + return Ok(current.clone()); + } + if next.contains(id) { + return Err(AddonError::Duplicate(id).into()); + } + if crate::utils::exists(&destination).await? { + return Err(AddonError::TargetExists(destination).into()); + } + next_config::save(prepared.join("release.toml"), record.as_ref()).await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + async_fs::rename(prepared, destination).await?; + next.dependencies.insert(id, record.clone()); + self.publish(next); + Ok(record) } fn state(&self) -> Arc { @@ -248,57 +317,126 @@ impl Addons { Ok(stage) } - /// Reloads both indexes before notifying watchers of a coherent snapshot. - async fn publish( - &self, - component_catalog: Option>>, - dependency_catalog: Option>>, - ) -> Result<()> { - let state = AddonsState::load( - component_catalog, - dependency_catalog, - self.0.context.directories(), - ) - .await?; + /// Publishes the already committed local snapshot without filesystem discovery. + fn publish(&self, state: AddonsState) { self.0.published.send_replace(Arc::new(state)); - Ok(()) } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] struct AddonsState { - components: AddonIndex, - dependencies: AddonIndex, + component_catalog: Option>>, + dependency_catalog: Option>>, + components: HashMap>>, + dependencies: HashMap>>, } - impl AddonsState { - /// Loads local indexes while tolerating unavailable catalog caches. async fn load_cached(directories: &Directories) -> Result { - let component_catalog = Catalog::::load(directories).await; - let dependency_catalog = Catalog::::load(directories).await; - Self::load(component_catalog, dependency_catalog, directories).await + let mut state = Self { + component_catalog: Catalog::::load(directories).await, + dependency_catalog: Catalog::::load(directories).await, + ..Self::default() + }; + for (id, path) in release_manifests(&directories.component_releases()).await? { + let record: Release = next_config::load(&path).await?; + record.validate(&path)?; + if record.id() != id { + return Err(AddonError::InvalidRelease(path).into()); + } + if state.contains(id) { + return Err(AddonError::Duplicate(id).into()); + } + record.require_payload(directories).await?; + state.components.insert(id, Arc::new(record)); + } + for (id, path) in release_manifests(&directories.dependency_releases()).await? { + let record: Release = next_config::load(&path).await?; + record.validate(&path)?; + if record.id() != id { + return Err(AddonError::InvalidRelease(path).into()); + } + if state.contains(id) { + return Err(AddonError::Duplicate(id).into()); + } + record.require_payload(directories).await?; + state.dependencies.insert(id, Arc::new(record)); + } + Ok(state) } - async fn load( - component_catalog: Option>>, - dependency_catalog: Option>>, - directories: &Directories, - ) -> Result { - let components = AddonIndex::::load(directories).await?; - let components = match component_catalog { - Some(catalog) => components.with_catalog(catalog), - None => components, - }; - let dependencies = AddonIndex::::load(directories).await?; - let dependencies = match dependency_catalog { - Some(catalog) => dependencies.with_catalog(catalog), - None => dependencies, - }; - Ok(Self { - components, - dependencies, - }) + fn contains(&self, id: Uuid) -> bool { + self.components.contains_key(&id) || self.dependencies.contains_key(&id) + } +} + +// Component archives have one top-level directory, which becomes the payload. +async fn prepare_component_archive( + archive: &Path, + stage: &Path, + cancellation: &CancellationToken, +) -> Result { + let extracted = stage.join("extracted"); + async_fs::create_dir(&extracted).await?; + cancellation + .run_until_cancelled(crate::utils::archive::extract(archive, &extracted)) + .await + .ok_or(Error::Cancelled)??; + let mut entries = async_fs::read_dir(&extracted).await?; + let Some(entry) = entries.try_next().await? else { + return Err(AddonError::InvalidComponentArchive.into()); + }; + if entries.try_next().await?.is_some() || !entry.file_type().await?.is_dir() { + return Err(AddonError::InvalidComponentArchive.into()); + } + let source = entry.path(); + check_component_links(&source, cancellation).await?; + let prepared = stage.join("release"); + async_fs::create_dir(&prepared).await?; + async_fs::rename(source, prepared.join("payload")).await?; + Ok(prepared) +} + +// Component links must stay inside the component tree after it leaves staging. +async fn check_component_links(root: &Path, cancellation: &CancellationToken) -> Result<()> { + let root = async_fs::canonicalize(root).await?; + let mut pending = vec![root.clone()]; + while let Some(directory) = pending.pop() { + let mut entries = async_fs::read_dir(directory).await?; + while let Some(entry) = entries.try_next().await? { + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let path = entry.path(); + let kind = entry.file_type().await?; + if kind.is_dir() { + pending.push(path); + } else if kind.is_symlink() { + let target = async_fs::read_link(&path).await?; + crate::utils::archive::safe_symlink_target( + path.strip_prefix(&root).unwrap(), + target, + )?; + if !async_fs::canonicalize(&path).await?.starts_with(&root) { + return Err(AddonError::InvalidComponent(path).into()); + } + } + } + } + Ok(()) +} + +async fn release_manifests(root: &Path) -> Result> { + let mut manifests = Vec::new(); + let mut entries = async_fs::read_dir(root).await?; + while let Some(entry) = entries.try_next().await? { + if !entry.file_type().await?.is_dir() { + continue; + } + if let Ok(id) = Uuid::parse_str(&entry.file_name().to_string_lossy()) { + manifests.push((id, entry.path().join("release.toml"))); + } } + Ok(manifests) } /// Drives a download, translating its latest byte counts and cancellation result. diff --git a/src/addons/mod.rs b/src/addons/mod.rs index 16b8c0a..42fccd3 100644 --- a/src/addons/mod.rs +++ b/src/addons/mod.rs @@ -3,14 +3,13 @@ //! Addons pass through three representations: //! //! - [`CatalogEntry`] describes a release advertised by a remote catalog. -//! - [`IndexEntry`] describes a downloaded or hand-placed release in shared -//! storage. Dependency entries retain the artifacts needed for installation. +//! - [`Release`] describes a local release with its frozen recipe and source payload. //! - [`Addon`] is the artifact-free selection persisted in a //! [`crate::BottleState`]. //! //! Obtain the shared [`Addons`] manager from [`crate::Bottles::addons`]. Catalog -//! queries use the last successfully loaded catalog, while index queries expose -//! locally available releases. Fetching an entry only places it in shared +//! queries use the last successfully loaded catalog, while release queries expose +//! downloaded or imported releases. Fetching an entry only places it in shared //! storage; select components with [`crate::Bottle::set_component`] and install //! dependencies with [`crate::Bottle::install`]. @@ -21,19 +20,17 @@ use serde::{Deserialize, Deserializer, de}; mod addon; mod catalog; mod error; -mod index; mod installer; mod manager; +mod release; pub use addon::{Addon, Component, Dependency, Requirement, Slot}; pub use catalog::CatalogEntry; pub(crate) use catalog::Checksum; pub use error::{AddonError, CatalogError, InstallerError}; -pub use index::IndexEntry; -#[cfg(feature = "fvs")] -pub(crate) use installer::Artifact; -pub(crate) use installer::{InstallInputs, execute, recipe_steps, replay_env_vars, uninstall}; +pub(crate) use installer::{InstallInputs, execute, replay_env_vars, uninstall}; pub use manager::Addons; +pub use release::Release; /// Rejects empty or whitespace-only input without trimming accepted values. pub(crate) fn deserialize_non_empty_string<'de, D>(deserializer: D) -> Result diff --git a/src/addons/release.rs b/src/addons/release.rs new file mode 100644 index 0000000..02e6dc4 --- /dev/null +++ b/src/addons/release.rs @@ -0,0 +1,247 @@ +//! Immutable local releases, published and removed together with their payloads. + +use super::{ + Addon, AddonError, Component, Dependency, Requirement, Slot, + catalog::AddonFamily, + installer::{InstallResource, InstallStep}, +}; +use crate::{Directories, error::Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use uuid::{NonNilUuid, Uuid}; + +/// An immutable local release, stored with its source payload. +/// Returned handles are metadata snapshots; paths use the active data directory. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(bound(serialize = "K: Serialize", deserialize = "K: Deserialize<'de>"))] +pub struct Release { + #[serde(flatten)] + addon: Addon, + resources: Vec, +} + +impl Release { + /// Selection metadata suitable for owner state. + pub fn addon(&self) -> &Addon { + &self.addon + } + /// Immutable release identity. + pub fn id(&self) -> Uuid { + self.addon.id() + } + /// Release display name. + pub fn name(&self) -> &str { + self.addon.name() + } + /// Release version attribute. + pub fn version(&self) -> &str { + self.addon.version() + } + /// Required coexisting addons. + pub fn requirements(&self) -> &[Requirement] { + self.addon.requirements() + } + pub(crate) fn path(&self, directories: &Directories) -> PathBuf + where + K: AddonFamily, + { + self.directory(directories).join("payload") + } + pub(super) fn directory(&self, directories: &Directories) -> PathBuf + where + K: AddonFamily, + { + K::releases(directories).join(self.id().to_string()) + } + pub(crate) fn resources(&self) -> &[InstallResource] { + &self.resources + } + pub(crate) fn recipe(&self) -> impl DoubleEndedIterator { + self.resources.iter().flat_map(|r| &r.steps) + } +} +impl From<&Release> for Addon { + fn from(entry: &Release) -> Self { + entry.addon.clone() + } +} +impl Release { + pub(crate) async fn require_payload(&self, directories: &Directories) -> Result<()> { + if !async_fs::metadata(self.path(directories)) + .await + .is_ok_and(|m| m.is_dir()) + { + return Err(AddonError::PayloadMissing(self.id()).into()); + } + Ok(()) + } + + pub(super) fn validate(&self, path: &Path) -> Result<()> { + if self.resources.len() != 1 || !self.resources[0].path.as_os_str().is_empty() { + return Err(AddonError::InvalidRelease(path.to_path_buf()).into()); + } + Ok(()) + } + + pub(crate) fn new_component( + id: NonNilUuid, + name: String, + version: String, + slot: Slot, + requirements: Vec, + resource: InstallResource, + ) -> Self { + Self { + addon: Addon::new(id, name, version, requirements, Component { slot }), + resources: vec![resource], + } + } + + /// Component role occupied by this release. + pub fn slot(&self) -> Slot { + self.addon.slot() + } +} +impl Release { + pub(crate) async fn require_payload(&self, directories: &Directories) -> Result<()> { + let payload = self.path(directories); + for resource in &self.resources { + if !async_fs::metadata(payload.join(&resource.path)) + .await + .is_ok_and(|m| m.is_file()) + { + return Err(AddonError::PayloadMissing(self.id()).into()); + } + } + Ok(()) + } + + pub(super) fn validate(&self, path: &Path) -> Result<()> { + let mut names = std::collections::HashSet::new(); + if self.resources.is_empty() + || self + .resources + .iter() + .any(|r| !single_name(&r.path) || !names.insert(&r.path)) + { + return Err(AddonError::InvalidRelease(path.to_path_buf()).into()); + } + Ok(()) + } + + pub(crate) fn new_dependency( + id: NonNilUuid, + name: String, + version: String, + requirements: Vec, + resources: Vec, + ) -> Self { + Self { + addon: Addon::new(id, name, version, requirements, Dependency::default()), + resources, + } + } +} + +impl next_config::Config for Release { + const VERSION: u32 = 1; +} +fn single_name(path: &Path) -> bool { + let mut parts = path.components(); + matches!(parts.next(), Some(std::path::Component::Normal(_))) && parts.next().is_none() +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use super::*; + + fn id() -> NonNilUuid { + NonNilUuid::new(Uuid::new_v4()).unwrap() + } + + #[test] + fn release_flattens_addon_and_rejects_unknown_fields() { + let entry = Release::new_dependency( + id(), + "dependency".into(), + "1.0.0".into(), + vec![Requirement::Slot(Slot::Runner)], + vec![InstallResource::new("setup.exe", Vec::new())], + ); + let value = serde_json::to_value(&entry).unwrap(); + + assert!(value.get("addon").is_none()); + assert_eq!(value["name"], "dependency"); + assert_eq!(value["resources"][0]["path"], "setup.exe"); + assert_eq!( + serde_json::from_value::>(value.clone()).unwrap(), + entry + ); + + let addon = Addon::from(&entry); + let addon_value = serde_json::to_value(&addon).unwrap(); + assert!(addon_value.get("resources").is_none()); + assert_eq!(addon.id(), entry.id()); + assert_eq!(addon.requirements(), entry.requirements()); + + let mut unknown_entry = value; + unknown_entry + .as_object_mut() + .unwrap() + .insert("unknown".into(), Value::Bool(true)); + assert!(serde_json::from_value::>(unknown_entry).is_err()); + + let mut unknown_addon = addon_value; + unknown_addon + .as_object_mut() + .unwrap() + .insert("unknown".into(), Value::Bool(true)); + assert!(serde_json::from_value::>(unknown_addon).is_err()); + } + + #[test] + fn release_paths_use_active_directories() { + let root = std::env::temp_dir().join(format!("bottles-next-{}", Uuid::new_v4())); + let directories = Directories::from_path(&root).unwrap(); + let component = Release::new_component( + id(), + "runner".into(), + "1.0.0".into(), + Slot::Runner, + Vec::new(), + InstallResource::new("", Vec::new()), + ); + let dependency = Release::new_dependency( + id(), + "dependency".into(), + "1.0.0".into(), + Vec::new(), + vec![InstallResource::new("setup.exe", Vec::new())], + ); + + assert_eq!( + component + .path(&directories) + .join(&component.resources()[0].path), + directories + .components() + .join("releases") + .join(component.id().to_string()) + .join("payload") + ); + assert_eq!( + dependency + .path(&directories) + .join(&dependency.resources()[0].path), + directories + .dependencies() + .join("releases") + .join(dependency.id().to_string()) + .join("payload/setup.exe") + ); + let serialized = serde_json::to_string(&(component, dependency)).unwrap(); + assert!(!serialized.contains(root.to_string_lossy().as_ref())); + } +} diff --git a/src/bottle/tests.rs b/src/bottle/tests.rs index 0c3aafd..fcbcc5c 100644 --- a/src/bottle/tests.rs +++ b/src/bottle/tests.rs @@ -113,9 +113,19 @@ fn load_skips_corrupt_bottles() { fn create_reports_all_missing_runtime_addons_before_creating_files() { futures_lite::future::block_on(async { let directories = test_directories(); - let runner_path = directories.components().join("runner/proton-test"); - std::fs::create_dir_all(&runner_path).unwrap(); - std::fs::write(runner_path.join("proton"), []).unwrap(); + let runner_path = directories.data_dir().join("proton-test.tar"); + let mut archive = + smol_tar::TarWriter::new(async_fs::File::create(&runner_path).await.unwrap()); + archive + .write( + smol_tar::TarRegularFile::new("proton-test/proton", 0, &[][..]) + .with_mode(0o755) + .into(), + ) + .await + .unwrap(); + archive.finish().await.unwrap(); + drop(archive); let context = Context::for_test( directories.clone(), Some(directories.data_dir().join("fvs2d")), @@ -123,19 +133,25 @@ fn create_reports_all_missing_runtime_addons_before_creating_files() { .unwrap(); let addons = Addons::load(context.clone(), None, None).await.unwrap(); let runner = addons - .components() - .into_iter() - .find(|addon| addon.slot() == Slot::Runner) + .import_component(&runner_path, Slot::Runner, "Proton", "proton-test") + .await .unwrap(); - assert_eq!(runner.path(&directories), runner_path); let runner_id = runner.id(); - assert!(directories.components().join("index.toml").is_file()); + assert_eq!( + runner.path(&directories), + directories + .component_releases() + .join(runner_id.to_string()) + .join("payload") + ); assert!( - !std::fs::read_to_string(directories.components().join("index.toml")) - .unwrap() - .contains("path =") + directories + .component_releases() + .join(runner_id.to_string()) + .join("release.toml") + .is_file() ); - assert!(!runner_path.join(".addon.toml").exists()); + assert!(!directories.components().join("index.toml").exists()); let unknown = uuid::Uuid::new_v4(); assert!(matches!( addons.fetch_component(unknown).await, @@ -181,6 +197,14 @@ fn create_reports_all_missing_runtime_addons_before_creating_files() { reloaded_addons.component(runner_id).unwrap().id(), runner_id ); + reloaded_addons.remove_component(runner_id).await.unwrap(); + assert!(reloaded_addons.component(runner_id).is_none()); + assert!( + !directories + .component_releases() + .join(runner_id.to_string()) + .exists() + ); std::fs::remove_dir_all(directories.data_dir()).unwrap(); }); } diff --git a/src/environment/config.rs b/src/environment/config.rs index 0d4eca9..69d1f49 100644 --- a/src/environment/config.rs +++ b/src/environment/config.rs @@ -178,23 +178,20 @@ impl EnvironmentConfig { .filter_map(|slot| self.component(slot)) } - /// Derives recipe variables from selections and UUID-pinned local dependency recipes. + /// Derives variables from the selected local releases' frozen recipes. pub(crate) fn addon_env_vars(&self, addons: &crate::Addons) -> Result { let mut vars = EnvVars::default(); for addon in self.ordered_components() { - crate::addons::replay_env_vars(&mut vars, crate::addons::recipe_steps(addon.slot())); + let release = addons + .component(addon.id()) + .ok_or(crate::AddonError::NotFound(addon.id()))?; + crate::addons::replay_env_vars(&mut vars, release.recipe()); } for addon in &self.dependencies { - let entry = addons + let release = addons .dependency(addon.id()) .ok_or(crate::AddonError::NotFound(addon.id()))?; - crate::addons::replay_env_vars( - &mut vars, - entry - .artifacts() - .iter() - .flat_map(|artifact| &artifact.steps), - ); + crate::addons::replay_env_vars(&mut vars, release.recipe()); } Ok(vars) } diff --git a/src/environment/prefix/standard.rs b/src/environment/prefix/standard.rs index 4c53f5c..ca82fa9 100644 --- a/src/environment/prefix/standard.rs +++ b/src/environment/prefix/standard.rs @@ -45,7 +45,8 @@ pub(super) async fn apply( cancellation: &CancellationToken, ) -> Result<()> { let mut removals = Vec::new(); - let mut installations = Vec::new(); + let mut components = Vec::new(); + let mut dependencies = Vec::new(); for slot in Slot::iter().filter(|slot| !slot.is_runtime()) { let old = previous.component(slot); let new = candidate.component(slot); @@ -53,18 +54,26 @@ pub(super) async fn apply( continue; } if let Some(new) = new { - installations.push(vec![new.artifact(cx.directories())]); + let release = addons + .component(new.id()) + .ok_or(AddonError::NotFound(new.id()))?; + release.require_payload(cx.directories()).await?; + components.push(release); } else if let Some(old) = old { - removals.push((old.id(), vec![old.artifact(cx.directories())])); + let release = addons + .component(old.id()) + .ok_or(AddonError::NotFound(old.id()))?; + removals.push(release); } } for new in &candidate.dependencies[previous.dependencies.len()..] { let downloaded = addons .dependency(new.id()) .ok_or(AddonError::NotFound(new.id()))?; - installations.push(downloaded.resources(cx.directories())); + downloaded.require_payload(cx.directories()).await?; + dependencies.push(downloaded); } - if removals.is_empty() && installations.is_empty() { + if removals.is_empty() && components.is_empty() && dependencies.is_empty() { return Ok(()); } let runner = candidate @@ -74,7 +83,7 @@ pub(super) async fn apply( let prefix = root.join("prefix"); let winebridge = candidate.winebridge().path(cx.directories()); let mut env_vars = previous.addon_env_vars(addons)?; - for (id, resources) in removals { + for release in removals { let result = uninstall( InstallInputs { prefix: &prefix, @@ -83,8 +92,8 @@ pub(super) async fn apply( env_vars: &mut env_vars, explicit_env_vars: &candidate.env_vars, }, - &resources, - id, + release.recipe(), + release.id(), cancellation, |_| { progress.send_replace(Some(Progress::new(Stage::Removing))); @@ -94,7 +103,15 @@ pub(super) async fn apply( runtime::stop(runner.as_ref(), &prefix).await?; result?; } - for resources in installations { + let installations = components + .iter() + .map(|r| (r.path(cx.directories()), r.resources())) + .chain( + dependencies + .iter() + .map(|r| (r.path(cx.directories()), r.resources())), + ); + for (payload, resources) in installations { let result = execute( InstallInputs { prefix: &prefix, @@ -103,7 +120,8 @@ pub(super) async fn apply( env_vars: &mut env_vars, explicit_env_vars: &candidate.env_vars, }, - &resources, + &payload, + resources, cancellation, |_| { progress.send_replace(Some(Progress::new(Stage::Configuring))); diff --git a/src/environment/prefix/virgo/artifacts/mod.rs b/src/environment/prefix/virgo/artifacts/mod.rs index 003ac83..8f964b2 100644 --- a/src/environment/prefix/virgo/artifacts/mod.rs +++ b/src/environment/prefix/virgo/artifacts/mod.rs @@ -7,7 +7,7 @@ pub(crate) use software::prepare_addon; use super::VirgoError; use crate::environment::prefix::FVS_BLOCK_SIZE; use crate::{ - Addon, Addons, CatalogEntry, Component, Context, IndexEntry, Slot, + Addon, Addons, CatalogEntry, Component, Context, Release, Slot, error::{Error, Result}, runner::Runner, }; @@ -54,7 +54,7 @@ fn downloaded_soda( id: Uuid, version: &str, addons: &Addons, -) -> Result>> { +) -> Result>> { addons .component(id) .filter(|entry| entry.slot() == Slot::Runner && entry.version() == version) diff --git a/src/environment/prefix/virgo/artifacts/software.rs b/src/environment/prefix/virgo/artifacts/software.rs index 5203954..e6de37d 100644 --- a/src/environment/prefix/virgo/artifacts/software.rs +++ b/src/environment/prefix/virgo/artifacts/software.rs @@ -7,18 +7,10 @@ use uuid::Uuid; use super::{cache, downloaded_soda, ensure_base}; use crate::{ AddonError, Addons, Context, EnvVars, EnvironmentError, Progress, Slot, Stage, - addons::{Artifact, InstallInputs, execute}, + addons::{InstallInputs, execute}, error::{Error, Result}, }; -fn resources(id: Uuid, addons: &Addons, cx: &Context) -> Result> { - if let Some(component) = addons.component(id) { - return Ok(vec![component.artifact(cx.directories())]); - } - let dependency = addons.dependency(id).ok_or(AddonError::NotFound(id))?; - Ok(dependency.resources(cx.directories())) -} - pub(crate) async fn prepare_addon( id: Uuid, addons: &Addons, @@ -34,6 +26,17 @@ pub(crate) async fn prepare_addon( if cache::exists(id, cx).await? { return Ok(()); } + let component = addons.component(id); + let dependency = addons.dependency(id); + let (payload, resources) = if let Some(release) = &component { + release.require_payload(cx.directories()).await?; + (release.path(cx.directories()), release.resources()) + } else if let Some(release) = &dependency { + release.require_payload(cx.directories()).await?; + (release.path(cx.directories()), release.resources()) + } else { + return Err(AddonError::NotFound(id).into()); + }; let base = ensure_base(addons, cx, cancellation).await?; let soda = downloaded_soda(base.soda.id(), base.soda.version(), addons)?; let runner = soda.addon().load_runner(cx.directories(), None).await?; @@ -45,7 +48,6 @@ pub(crate) async fn prepare_addon( return Err(Error::Cancelled); } let mut env_vars = EnvVars::default(); - let resources = resources(id, addons, cx)?; cache::install( base.layer, id, @@ -59,7 +61,8 @@ pub(crate) async fn prepare_addon( env_vars: &mut env_vars, explicit_env_vars: &EnvVars::default(), }, - &resources, + &payload, + resources, cancellation, |_| { progress.send_replace(Some(Progress::new(Stage::Configuring))); diff --git a/src/lib.rs b/src/lib.rs index 8694d14..e0aedd2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,8 +14,8 @@ mod winebridge; mod wrapper; pub use addons::{ - Addon, AddonError, Addons, CatalogEntry, CatalogError, Component, Dependency, IndexEntry, - InstallerError, Requirement, Slot, + Addon, AddonError, Addons, CatalogEntry, CatalogError, Component, Dependency, InstallerError, + Release, Requirement, Slot, }; pub use bottle::{ Bottle, BottleError, BottleManager, BottleState, DllOverride, DllOverrideMode, GamescopeConfig, diff --git a/src/utils/archive.rs b/src/utils/archive.rs index 393829a..baf549c 100644 --- a/src/utils/archive.rs +++ b/src/utils/archive.rs @@ -108,7 +108,10 @@ fn safe_path(path: impl AsRef) -> Result { Ok(result) } -fn safe_symlink_target(link: &Path, target: &str) -> Result<(), ArchiveError> { +pub(crate) fn safe_symlink_target( + link: &Path, + target: impl AsRef, +) -> Result<(), ArchiveError> { safe_path(link.parent().unwrap_or(Path::new("")).join(target))?; Ok(()) } diff --git a/src/utils/directories.rs b/src/utils/directories.rs index 4680ade..7d698f9 100644 --- a/src/utils/directories.rs +++ b/src/utils/directories.rs @@ -68,6 +68,14 @@ impl Directories { self.data_dir().join("dependencies") } + pub(crate) fn component_releases(&self) -> PathBuf { + self.components().join("releases") + } + + pub(crate) fn dependency_releases(&self) -> PathBuf { + self.dependencies().join("releases") + } + pub(crate) fn plugins(&self) -> PathBuf { self.data_dir().join("plugins") } @@ -76,7 +84,7 @@ impl Directories { self.config_dir().join("profiles.toml") } - fn paths(&self) -> [PathBuf; 7] { + fn paths(&self) -> [PathBuf; 9] { [ self.config_dir().to_path_buf(), self.data_dir().to_path_buf(), @@ -85,6 +93,8 @@ impl Directories { self.components(), self.dependencies(), self.plugins(), + self.component_releases(), + self.dependency_releases(), ] } } From fb89ae84f40b49377bacc1b93c46c476f3df4ba0 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Fri, 11 Sep 2026 23:41:33 +0530 Subject: [PATCH 16/24] refactor(addons): persist runtime variables in selections --- README.md | 29 +-- src/addons/addon.rs | 19 +- src/addons/manager/fetch.rs | 6 +- src/addons/manager/import.rs | 15 +- src/addons/manager/mod.rs | 14 +- src/addons/mod.rs | 2 +- src/addons/release.rs | 184 ++++++------------ src/bottle/state.rs | 2 +- src/bottle/tests.rs | 9 + src/environment/config.rs | 18 +- src/environment/mod.rs | 2 +- src/environment/prefix/standard.rs | 8 +- .../prefix/virgo/artifacts/software.rs | 4 +- 13 files changed, 125 insertions(+), 187 deletions(-) diff --git a/README.md b/README.md index dbf49c5..72b01e8 100644 --- a/README.md +++ b/README.md @@ -90,16 +90,18 @@ The manager keeps separate typed component and dependency maps, with UUID unique checked across both families. Each ordered resource keeps a path relative to `payload/` and its recipe. Components use the payload directory itself; dependency resources use local filenames. -Download URLs and checksums remain in the catalog and are used only during fetching. `Addon` remains the lightweight -selection in bottle state. A local release exists only as a complete record and +Download URLs and checksums remain in the catalog and are used only during fetching. +`Addon` preserves identity, requirements, and frozen runtime variables in owner +state. A local release exists only as a complete record and payload. Loading rejects incomplete releases. Removal renames the entire release directory out of its published location, removes it from the typed map, then deletes the withdrawn directory. A deletion failure leaves only unpublished staging data. -Existing environments keep their selections and report missing releases when needed. +Existing environments keep their runtime variables after removal. Source installation +and runtime executables still require their payloads. Fetching a removed release resolves it from the current catalog; imported components must be imported again with a new UUID. Built Virgo caches are not removed. -In catalog format 2, omitted component `steps` use the bundled recipe for that slot. +Omitted component `steps` use the bundled recipe for that slot. Explicit `steps` replace the default entirely; `steps: []` means no installation steps. The resolved recipe is frozen into the local release, so template updates do not change existing releases. Dependency steps come from the catalog (omitted means empty), @@ -114,7 +116,9 @@ supported. Imports work offline and leave the source archive untouched. Executab permissions and internal relative symlinks are preserved; escaping links are rejected. Catalog component downloads and local imports share archive preparation and release publication; catalog downloads additionally transfer and verify the archive. Templates are never read during installation -or startup. Old indexes, slot/version directories, and the former top-level `releases/` directory +or startup. Publication and loading check recognized runner layouts, WineBridge/UMU +entrypoints, and recipe Copy sources before making a component selectable. +Old indexes, slot/version directories, and the former top-level `releases/` directory are ignored and left untouched; re-download or explicitly import a component archive. There is no automatic migration. @@ -149,16 +153,19 @@ files, updates, saves, and whiteouts keep normal overlay precedence; the upper i never pruned. A later WineBridge startup failure does not undo successful registry preparation. -Recipe environment variables are derived from durable release records when starting -an environment or running Standard installers. Both backends require the selected -local releases for these recipes; Virgo caches contain filesystem and registry -effects, not runtime variables. Catalog and bundled-template updates cannot change -a local release's recipe. Explicit -settings take precedence; execution-owned variables such as +Runtime variables are derived once from resolved recipes during acquisition and +saved in `Addon`. Both backends merge these saved values in component slot order, +then dependency order, without resolving shared releases. Virgo caches contain +filesystem and registry effects. Installation still executes ordered environment +steps so commands see the variables declared so far. Explicit owner settings take +precedence; execution-owned variables such as `WINEPREFIX`, `WINEARCH`, and `PROTONPATH` are applied last. Snapshots capture owner metadata, addon selections, registry baseline, and private prefix data while stopped, without WineBridge discovery files. +Standard component removal still uses the shared release’s recipe. Removing that +release preserves runtime variables but makes recipe-based uninstallation unavailable. + Bottle configuration uses version 1. `EnvironmentConfig::backend` selects `PrefixBackend::Standard` or `PrefixBackend::Virgo` and is serialized under the existing `environment.storage` key. Layers are derived from selected addons rather than stored diff --git a/src/addons/addon.rs b/src/addons/addon.rs index 511d7d4..08fb297 100644 --- a/src/addons/addon.rs +++ b/src/addons/addon.rs @@ -7,7 +7,7 @@ use strum::EnumIter; use uuid::{NonNilUuid, Uuid}; use crate::{ - Directories, + Directories, EnvVars, error::Result, runner::{Proton, Runner, RunnerError, RunnerKind, Wine, detect_runner_kind}, }; @@ -15,8 +15,8 @@ use crate::{ /// An addon selection persisted in a bottle. /// /// `K` is [`Component`] or [`Dependency`]. Unlike an [`Release`](super::Release), -/// this value contains no download artifacts; it remains sufficient for requirement -/// validation and for locating or removing a selected component. +/// this value contains no installation resources; it preserves requirements and +/// runtime variables independently of the shared release. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde( deny_unknown_fields, @@ -28,6 +28,7 @@ pub struct Addon { version: String, #[serde(default, skip_serializing_if = "Vec::is_empty")] requirements: Vec, + env_vars: EnvVars, #[serde(flatten)] kind: K, } @@ -38,6 +39,7 @@ impl Addon { name: String, version: String, requirements: Vec, + env_vars: EnvVars, kind: K, ) -> Self { Self { @@ -45,6 +47,7 @@ impl Addon { name, version, requirements, + env_vars, kind, } } @@ -68,6 +71,16 @@ impl Addon { pub fn requirements(&self) -> &[Requirement] { &self.requirements } + + /// Returns this addon's frozen runtime environment variables. + /// + /// Values are derived from its resolved installation recipes during acquisition + /// and saved with the selection, so they remain available after the shared + /// release is removed. These are this addon's contributions only; environment + /// configuration combines them with other addons and applies owner overrides last. + pub fn env_vars(&self) -> &EnvVars { + &self.env_vars + } } impl Addon { diff --git a/src/addons/manager/fetch.rs b/src/addons/manager/fetch.rs index d6a15d9..3697af6 100644 --- a/src/addons/manager/fetch.rs +++ b/src/addons/manager/fetch.rs @@ -33,7 +33,7 @@ impl Addons { } if let Some(release) = addons.component(id) { release - .require_payload(addons.0.context.directories()) + .validate(&release.path(addons.0.context.directories())) .await?; return Ok(release); } @@ -71,7 +71,6 @@ impl Addons { .to_vec(), ), )); - record.validate(&record.directory(addons.0.context.directories()))?; let stage = addons.create_stage().await?; let result = async { let downloads = stage.join("downloads"); @@ -110,7 +109,7 @@ impl Addons { } if let Some(release) = addons.dependency(id) { release - .require_payload(addons.0.context.directories()) + .validate(&release.path(addons.0.context.directories())) .await?; return Ok(release); } @@ -138,7 +137,6 @@ impl Addons { }) .collect(), )); - record.validate(&record.directory(addons.0.context.directories()))?; let stage = addons.create_stage().await?; let result = async { let prepared = stage.join("release"); diff --git a/src/addons/manager/import.rs b/src/addons/manager/import.rs index 8a8fea0..99b34df 100644 --- a/src/addons/manager/import.rs +++ b/src/addons/manager/import.rs @@ -1,8 +1,8 @@ //! Explicit local component archive import. Templates are frozen into fresh releases. use super::super::{ - AddonError, Component, Release, Requirement, Slot, - installer::{InstallResource, InstallStep, recipe_steps}, + Component, Release, Requirement, Slot, + installer::{InstallResource, recipe_steps}, }; use super::{Addons, prepare_component_archive}; use crate::{ @@ -34,17 +34,6 @@ impl Addons { let payload = prepared.join("payload"); let requirements = inspect_release(slot, &payload).await?; let steps = recipe_steps(slot).to_vec(); - // Template layouts must match the imported files. - for step in &steps { - if let InstallStep::Copy { source, .. } = step { - if !async_fs::metadata(payload.join(source)) - .await - .is_ok_and(|m| m.is_file()) - { - return Err(AddonError::InvalidComponent(payload).into()); - } - } - } let id = Uuid::new_v4(); let release = Release::new_component( NonNilUuid::new(id).unwrap(), diff --git a/src/addons/manager/mod.rs b/src/addons/manager/mod.rs index 19be2fe..98cec3b 100644 --- a/src/addons/manager/mod.rs +++ b/src/addons/manager/mod.rs @@ -228,7 +228,7 @@ impl Addons { ) -> Result>> { let id = record.id(); let destination = record.directory(self.0.context.directories()); - record.validate(&destination)?; + record.validate(&prepared.join("payload")).await?; let _write = cancellation .run_until_cancelled(self.0.write.lock()) .await @@ -242,7 +242,7 @@ impl Addons { return Err(AddonError::InvalidRelease(destination).into()); } current - .require_payload(self.0.context.directories()) + .validate(¤t.path(self.0.context.directories())) .await?; return Ok(current.clone()); } @@ -270,7 +270,7 @@ impl Addons { ) -> Result>> { let id = record.id(); let destination = record.directory(self.0.context.directories()); - record.validate(&destination)?; + record.validate(&prepared.join("payload")).await?; let _write = cancellation .run_until_cancelled(self.0.write.lock()) .await @@ -284,7 +284,7 @@ impl Addons { return Err(AddonError::InvalidRelease(destination).into()); } current - .require_payload(self.0.context.directories()) + .validate(¤t.path(self.0.context.directories())) .await?; return Ok(current.clone()); } @@ -339,26 +339,24 @@ impl AddonsState { }; for (id, path) in release_manifests(&directories.component_releases()).await? { let record: Release = next_config::load(&path).await?; - record.validate(&path)?; if record.id() != id { return Err(AddonError::InvalidRelease(path).into()); } if state.contains(id) { return Err(AddonError::Duplicate(id).into()); } - record.require_payload(directories).await?; + record.validate(&record.path(directories)).await?; state.components.insert(id, Arc::new(record)); } for (id, path) in release_manifests(&directories.dependency_releases()).await? { let record: Release = next_config::load(&path).await?; - record.validate(&path)?; if record.id() != id { return Err(AddonError::InvalidRelease(path).into()); } if state.contains(id) { return Err(AddonError::Duplicate(id).into()); } - record.require_payload(directories).await?; + record.validate(&record.path(directories)).await?; state.dependencies.insert(id, Arc::new(record)); } Ok(state) diff --git a/src/addons/mod.rs b/src/addons/mod.rs index 42fccd3..588c71e 100644 --- a/src/addons/mod.rs +++ b/src/addons/mod.rs @@ -28,7 +28,7 @@ pub use addon::{Addon, Component, Dependency, Requirement, Slot}; pub use catalog::CatalogEntry; pub(crate) use catalog::Checksum; pub use error::{AddonError, CatalogError, InstallerError}; -pub(crate) use installer::{InstallInputs, execute, replay_env_vars, uninstall}; +pub(crate) use installer::{InstallInputs, execute, uninstall}; pub use manager::Addons; pub use release::Release; diff --git a/src/addons/release.rs b/src/addons/release.rs index 02e6dc4..4948b80 100644 --- a/src/addons/release.rs +++ b/src/addons/release.rs @@ -5,7 +5,7 @@ use super::{ catalog::AddonFamily, installer::{InstallResource, InstallStep}, }; -use crate::{Directories, error::Result}; +use crate::{Directories, EnvVars, error::Result}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use uuid::{NonNilUuid, Uuid}; @@ -66,19 +66,37 @@ impl From<&Release> for Addon { } } impl Release { - pub(crate) async fn require_payload(&self, directories: &Directories) -> Result<()> { - if !async_fs::metadata(self.path(directories)) - .await - .is_ok_and(|m| m.is_dir()) - { + /// Validates the resource structure and component layout at a staged or published payload. + pub(crate) async fn validate(&self, payload: &Path) -> Result<()> { + if self.resources.len() != 1 || !self.resources[0].path.as_os_str().is_empty() { + return Err(AddonError::InvalidRelease(payload.to_path_buf()).into()); + } + if !async_fs::metadata(payload).await.is_ok_and(|m| m.is_dir()) { return Err(AddonError::PayloadMissing(self.id()).into()); } - Ok(()) - } - - pub(super) fn validate(&self, path: &Path) -> Result<()> { - if self.resources.len() != 1 || !self.resources[0].path.as_os_str().is_empty() { - return Err(AddonError::InvalidRelease(path.to_path_buf()).into()); + let marker = match self.slot() { + Slot::Runner => { + crate::runner::detect_runner_kind(payload).await?; + None + } + Slot::WineBridge => Some("bottles-winebridge.exe"), + Slot::Umu => Some("umu-run"), + _ => None, + }; + let sources = marker + .map(Path::new) + .into_iter() + .chain(self.recipe().filter_map(|step| match step { + InstallStep::Copy { source, .. } => Some(source.as_path()), + _ => None, + })); + for source in sources { + if !async_fs::metadata(payload.join(source)) + .await + .is_ok_and(|entry| entry.is_file()) + { + return Err(AddonError::InvalidComponent(payload.to_path_buf()).into()); + } } Ok(()) } @@ -91,8 +109,17 @@ impl Release { requirements: Vec, resource: InstallResource, ) -> Self { + let mut env_vars = EnvVars::default(); + super::installer::replay_env_vars(&mut env_vars, &resource.steps); Self { - addon: Addon::new(id, name, version, requirements, Component { slot }), + addon: Addon::new( + id, + name, + version, + requirements, + env_vars, + Component { slot }, + ), resources: vec![resource], } } @@ -103,8 +130,12 @@ impl Release { } } impl Release { - pub(crate) async fn require_payload(&self, directories: &Directories) -> Result<()> { - let payload = self.path(directories); + /// Validates unique resource paths and files at a staged or published payload. + pub(crate) async fn validate(&self, payload: &Path) -> Result<()> { + let mut names = std::collections::HashSet::new(); + if self.resources.is_empty() || self.resources.iter().any(|r| !names.insert(&r.path)) { + return Err(AddonError::InvalidRelease(payload.to_path_buf()).into()); + } for resource in &self.resources { if !async_fs::metadata(payload.join(&resource.path)) .await @@ -116,19 +147,6 @@ impl Release { Ok(()) } - pub(super) fn validate(&self, path: &Path) -> Result<()> { - let mut names = std::collections::HashSet::new(); - if self.resources.is_empty() - || self - .resources - .iter() - .any(|r| !single_name(&r.path) || !names.insert(&r.path)) - { - return Err(AddonError::InvalidRelease(path.to_path_buf()).into()); - } - Ok(()) - } - pub(crate) fn new_dependency( id: NonNilUuid, name: String, @@ -136,8 +154,17 @@ impl Release { requirements: Vec, resources: Vec, ) -> Self { + let mut env_vars = EnvVars::default(); + super::installer::replay_env_vars(&mut env_vars, resources.iter().flat_map(|r| &r.steps)); Self { - addon: Addon::new(id, name, version, requirements, Dependency::default()), + addon: Addon::new( + id, + name, + version, + requirements, + env_vars, + Dependency::default(), + ), resources, } } @@ -146,102 +173,3 @@ impl Release { impl next_config::Config for Release { const VERSION: u32 = 1; } -fn single_name(path: &Path) -> bool { - let mut parts = path.components(); - matches!(parts.next(), Some(std::path::Component::Normal(_))) && parts.next().is_none() -} - -#[cfg(test)] -mod tests { - use serde_json::Value; - - use super::*; - - fn id() -> NonNilUuid { - NonNilUuid::new(Uuid::new_v4()).unwrap() - } - - #[test] - fn release_flattens_addon_and_rejects_unknown_fields() { - let entry = Release::new_dependency( - id(), - "dependency".into(), - "1.0.0".into(), - vec![Requirement::Slot(Slot::Runner)], - vec![InstallResource::new("setup.exe", Vec::new())], - ); - let value = serde_json::to_value(&entry).unwrap(); - - assert!(value.get("addon").is_none()); - assert_eq!(value["name"], "dependency"); - assert_eq!(value["resources"][0]["path"], "setup.exe"); - assert_eq!( - serde_json::from_value::>(value.clone()).unwrap(), - entry - ); - - let addon = Addon::from(&entry); - let addon_value = serde_json::to_value(&addon).unwrap(); - assert!(addon_value.get("resources").is_none()); - assert_eq!(addon.id(), entry.id()); - assert_eq!(addon.requirements(), entry.requirements()); - - let mut unknown_entry = value; - unknown_entry - .as_object_mut() - .unwrap() - .insert("unknown".into(), Value::Bool(true)); - assert!(serde_json::from_value::>(unknown_entry).is_err()); - - let mut unknown_addon = addon_value; - unknown_addon - .as_object_mut() - .unwrap() - .insert("unknown".into(), Value::Bool(true)); - assert!(serde_json::from_value::>(unknown_addon).is_err()); - } - - #[test] - fn release_paths_use_active_directories() { - let root = std::env::temp_dir().join(format!("bottles-next-{}", Uuid::new_v4())); - let directories = Directories::from_path(&root).unwrap(); - let component = Release::new_component( - id(), - "runner".into(), - "1.0.0".into(), - Slot::Runner, - Vec::new(), - InstallResource::new("", Vec::new()), - ); - let dependency = Release::new_dependency( - id(), - "dependency".into(), - "1.0.0".into(), - Vec::new(), - vec![InstallResource::new("setup.exe", Vec::new())], - ); - - assert_eq!( - component - .path(&directories) - .join(&component.resources()[0].path), - directories - .components() - .join("releases") - .join(component.id().to_string()) - .join("payload") - ); - assert_eq!( - dependency - .path(&directories) - .join(&dependency.resources()[0].path), - directories - .dependencies() - .join("releases") - .join(dependency.id().to_string()) - .join("payload/setup.exe") - ); - let serialized = serde_json::to_string(&(component, dependency)).unwrap(); - assert!(!serialized.contains(root.to_string_lossy().as_ref())); - } -} diff --git a/src/bottle/state.rs b/src/bottle/state.rs index ba52f17..6aab737 100644 --- a/src/bottle/state.rs +++ b/src/bottle/state.rs @@ -25,7 +25,7 @@ use crate::{Context, EnvironmentConfig, addons::Addons, error::Result}; /// They remain valid after the bottle changes or is deleted; /// their getters continue to return the values recorded when that particular /// snapshot was published. Obtain another snapshot to observe later changes. -/// Component locations are derived from their slot and version. +/// Component payload locations are derived from their UUIDs. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize, Config)] #[config(version = 1)] pub struct BottleState { diff --git a/src/bottle/tests.rs b/src/bottle/tests.rs index fcbcc5c..aa37c8c 100644 --- a/src/bottle/tests.rs +++ b/src/bottle/tests.rs @@ -137,6 +137,15 @@ fn create_reports_all_missing_runtime_addons_before_creating_files() { .await .unwrap(); let runner_id = runner.id(); + for slot in [Slot::WineBridge, Slot::Umu] { + assert!(matches!( + addons + .import_component(&runner_path, slot, "Invalid runtime", "99.0.0") + .await, + Err(Error::Addon(AddonError::InvalidComponent(_))) + )); + assert!(addons.latest_component(slot).is_none()); + } assert_eq!( runner.path(&directories), directories diff --git a/src/environment/config.rs b/src/environment/config.rs index 69d1f49..3b05df3 100644 --- a/src/environment/config.rs +++ b/src/environment/config.rs @@ -10,7 +10,7 @@ use strum::IntoEnumIterator; use uuid::Uuid; /// Execution settings embedded in a bottle or standalone program's saved state. -/// Virgo layers and recipe variables are derived from these selections on demand. +/// Selections preserve runtime contributions independently of installation inputs. #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct EnvironmentConfig { /// Prefix creation and software materialization strategy. @@ -178,22 +178,16 @@ impl EnvironmentConfig { .filter_map(|slot| self.component(slot)) } - /// Derives variables from the selected local releases' frozen recipes. - pub(crate) fn addon_env_vars(&self, addons: &crate::Addons) -> Result { + /// Combines saved addon contributions; later selections override earlier ones. + pub(crate) fn addon_env_vars(&self) -> EnvVars { let mut vars = EnvVars::default(); for addon in self.ordered_components() { - let release = addons - .component(addon.id()) - .ok_or(crate::AddonError::NotFound(addon.id()))?; - crate::addons::replay_env_vars(&mut vars, release.recipe()); + vars.extend(addon.env_vars().clone()); } for addon in &self.dependencies { - let release = addons - .dependency(addon.id()) - .ok_or(crate::AddonError::NotFound(addon.id()))?; - crate::addons::replay_env_vars(&mut vars, release.recipe()); + vars.extend(addon.env_vars().clone()); } - Ok(vars) + vars } /// Returns the runner recorded when this snapshot was published. diff --git a/src/environment/mod.rs b/src/environment/mod.rs index 4873511..29f45c1 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -59,7 +59,7 @@ impl Environment { if let Some(environment) = Self::try_attach(root).await? { return Ok(environment); } - let env_vars = config.addon_env_vars(addons)?; + let env_vars = config.addon_env_vars(); if cancellation.is_cancelled() { return Err(Error::Cancelled); } diff --git a/src/environment/prefix/standard.rs b/src/environment/prefix/standard.rs index ca82fa9..d6e4489 100644 --- a/src/environment/prefix/standard.rs +++ b/src/environment/prefix/standard.rs @@ -57,7 +57,7 @@ pub(super) async fn apply( let release = addons .component(new.id()) .ok_or(AddonError::NotFound(new.id()))?; - release.require_payload(cx.directories()).await?; + release.validate(&release.path(cx.directories())).await?; components.push(release); } else if let Some(old) = old { let release = addons @@ -70,7 +70,9 @@ pub(super) async fn apply( let downloaded = addons .dependency(new.id()) .ok_or(AddonError::NotFound(new.id()))?; - downloaded.require_payload(cx.directories()).await?; + downloaded + .validate(&downloaded.path(cx.directories())) + .await?; dependencies.push(downloaded); } if removals.is_empty() && components.is_empty() && dependencies.is_empty() { @@ -82,7 +84,7 @@ pub(super) async fn apply( .await?; let prefix = root.join("prefix"); let winebridge = candidate.winebridge().path(cx.directories()); - let mut env_vars = previous.addon_env_vars(addons)?; + let mut env_vars = previous.addon_env_vars(); for release in removals { let result = uninstall( InstallInputs { diff --git a/src/environment/prefix/virgo/artifacts/software.rs b/src/environment/prefix/virgo/artifacts/software.rs index e6de37d..e2260a9 100644 --- a/src/environment/prefix/virgo/artifacts/software.rs +++ b/src/environment/prefix/virgo/artifacts/software.rs @@ -29,10 +29,10 @@ pub(crate) async fn prepare_addon( let component = addons.component(id); let dependency = addons.dependency(id); let (payload, resources) = if let Some(release) = &component { - release.require_payload(cx.directories()).await?; + release.validate(&release.path(cx.directories())).await?; (release.path(cx.directories()), release.resources()) } else if let Some(release) = &dependency { - release.require_payload(cx.directories()).await?; + release.validate(&release.path(cx.directories())).await?; (release.path(cx.directories()), release.resources()) } else { return Err(AddonError::NotFound(id).into()); From 518db96525988ee3bba828531cae782b3bbc9051 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Sat, 12 Sep 2026 00:43:12 +0530 Subject: [PATCH 17/24] refactor(virgo): publish complete immutable addon artifacts --- README.md | 54 +++- src/addons/installer/engine.rs | 32 ++- src/addons/installer/mod.rs | 4 +- src/environment/prefix/standard.rs | 1 + .../prefix/virgo/artifacts/adapter.rs | 45 +++ .../prefix/virgo/artifacts/build.rs | 100 +++++++ .../prefix/virgo/artifacts/cache.rs | 270 +++++------------- src/environment/prefix/virgo/artifacts/mod.rs | 208 +++----------- .../prefix/virgo/artifacts/software.rs | 53 ++-- src/environment/prefix/virgo/mod.rs | 46 ++- src/environment/prefix/virgo/registry.rs | 148 +++++----- 11 files changed, 461 insertions(+), 500 deletions(-) create mode 100644 src/environment/prefix/virgo/artifacts/adapter.rs create mode 100644 src/environment/prefix/virgo/artifacts/build.rs diff --git a/README.md b/README.md index 72b01e8..3fa6c26 100644 --- a/README.md +++ b/README.md @@ -127,17 +127,56 @@ Virgo builds a clean shared base from the latest catalog runner named `Soda` downloaded. The base manifest pins its release and immutable FVS revision across catalog refreshes. The base is initialized once and reused; there is no rebuild operation. Stopped preparation builds missing artifacts and resolves the selected -composition before execution. New bases and adapters use -`virgo/soda`; existing manifest references and addon caches remain usable. +composition before execution. The complete base lives under `virgo/soda`, with +its manifest pinning the Soda UUID and exact commit. + +The base, addons, and runner adapters are each published as one immutable directory: + +```text +virgo/addons// # adapters: virgo/adapters//; base: virgo/soda/ + manifest.toml + filesystem/ + registry/ + user.reg + system.reg +``` + +The manifest records `_version`, the release `id`, and its exact FVS `commit` ID. +Repository and registry paths are derived from the artifact directory. Manifests +use `next_config` for versioned loading and saving; no migrations are defined. +The base's registry files contain initial hives; adapter and addon registry files +contain patches against those hives. The filesystem, both registry files, and +manifest are built in staging and published with one rename. Scratch mounts and temporary baselines stay outside +the published directory. Wine is stopped before diffing or unmounting; failed +shutdown or unmount retains staging for explicit cleanup. + +Preparation loads the complete artifact before looking up a source release or +waiting for the shared build lock. A cache miss acquires the lock and checks again +before building. Reads never wait for unrelated artifact construction. +A cache hit needs no addon source record or payload. Missing manifests, +repositories, or patches are errors; no registry changes are represented by +valid empty patches. Resolved artifacts supply layers and registry locations +directly to composition, without looking up repository history or resolving UUIDs +again. Existing artifacts are never rebuilt or replaced automatically. +Legacy `virgo/layers`, `virgo/registry`, and `virgo/base.toml` are not used. +The base layout is a clean format break: an old `virgo/soda` directory containing +`prefix/` and adapters is rejected, not migrated or overwritten. Recreating a base +requires explicitly clearing its dependent addon and adapter caches as well; +they were built against that pinned base. Existing data is left untouched. +There are no generation directories or upgrade-triggered rebuilds. Every addon recipe must install against pinned Soda alone. Cache construction uses no layers, registry patches, or environment contributions from other addons, and no owner settings, wrappers, or private writable data. Requirements are validated against the final environment selections. UUID remains the sole cache identity: completed caches survive runner and settings changes. Runner -adapters are built using the selected runner over the pinned base. Shared -construction is serialized within one core instance. Standard installers continue -using their owner's runner. +adapters are built using the selected runner over the pinned base. Adapter and +addon builds record registry changes as patches and exclude full hives from +their committed filesystem layers. Shared construction is serialized within one +core instance. Adapter and addon builds +share the same shutdown, diff, unmount, and publication workflow. Standard +installers continue using their owner's runner and preserve displaced files as +backups. Virgo disables those backups because the lower layer retains the originals. Virgo composition is always Soda base → selected runner adapter → components in slot order → dependencies in persisted order → private writable upper. Preparation @@ -145,8 +184,9 @@ resolves each selected addon's immutable layer by UUID and keeps the resulting stack local to that operation. There is no second persisted list of selections or layers. The shared base remains pinned and completed UUID caches remain immutable. -Each preparation reconstructs the managed registry baseline and reapplies private -changes relative to the previous baseline. The owner is checkpointed before that +Each preparation copies the base's published initial hives, then applies adapter +patches, addon patches in selection order, and private changes +relative to the previous baseline. The owner is checkpointed before that mutation; failure restores its prior data while keeping the selected configuration saved for retry. Shared artifact builds happen before the checkpoint. Private files, updates, saves, and whiteouts keep normal overlay precedence; the upper is diff --git a/src/addons/installer/engine.rs b/src/addons/installer/engine.rs index bc0f74b..ac41fac 100644 --- a/src/addons/installer/engine.rs +++ b/src/addons/installer/engine.rs @@ -19,6 +19,7 @@ use crate::{ use super::{InstallInputs, InstallResource, InstallStep}; /// Applies every resource and step sequentially, reporting each step before it starts. +/// `backup_files` preserves displaced files for Standard removal; layered builds disable it. /// /// Cancellation is checked before the first step, after every step, while waiting for child /// processes, between per-DLL operations, and during extraction. Cancellation attempts to kill @@ -28,6 +29,7 @@ pub(crate) async fn execute( inputs: InstallInputs<'_>, payload_root: &Path, resources: &[InstallResource], + backup_files: bool, cancellation: &CancellationToken, on_step: impl Fn(&InstallStep) + Send, ) -> Result<()> { @@ -53,6 +55,7 @@ pub(crate) async fn execute( }, &source, step, + backup_files, cancellation, ) .await?; @@ -136,6 +139,7 @@ async fn execute_step( inputs: InstallInputs<'_>, resource: &Path, step: &InstallStep, + backup_files: bool, cancellation: &CancellationToken, ) -> Result<()> { let InstallInputs { @@ -155,10 +159,10 @@ async fn execute_step( } else { resource.join(source) }; - install_file(&source, prefix, destination).await?; + install_file(&source, prefix, destination, backup_files).await?; } InstallStep::Extract { destination } => { - extract_into(resource, prefix, destination, cancellation).await?; + extract_into(resource, prefix, destination, backup_files, cancellation).await?; } InstallStep::Execute { arguments } => { let mut command = Command::new(resource); @@ -306,7 +310,7 @@ fn is_not_found(error: &Error) -> bool { matches!(error, Error::Status(status) if status.code() == tonic::Code::NotFound) } -/// Copies a file into a prefix, preserving the first displaced regular file as a backup. +/// Copies a file, optionally preserving the first displaced regular file for restoration. /// /// The backup is stored alongside the destination with `.bak` appended. An existing backup is /// never overwritten. `relative` is joined directly to `prefix` without containment validation. @@ -314,14 +318,20 @@ fn is_not_found(error: &Error) -> bool { /// # Panics /// /// Panics if the resulting destination has no parent directory. -async fn install_file(source: &Path, prefix: &Path, relative: &Path) -> Result<()> { +async fn install_file( + source: &Path, + prefix: &Path, + relative: &Path, + backup_files: bool, +) -> Result<()> { let destination = prefix.join(relative); async_fs::create_dir_all(destination.parent().expect("destination has a parent")).await?; let relative_backup = backup_path(relative); let backup = prefix.join(&relative_backup); - if async_fs::metadata(&destination) - .await - .is_ok_and(|entry| entry.is_file()) + if backup_files + && async_fs::metadata(&destination) + .await + .is_ok_and(|entry| entry.is_file()) && !exists(&backup).await? { async_fs::copy(&destination, &backup).await?; @@ -354,8 +364,8 @@ async fn uninstall_file(prefix: &Path, relative: &Path) -> io::Result<()> { /// Extracts an archive into an isolated staging directory, then installs its files. /// -/// Files are installed in sorted path order through [`install_file`], preserving displaced files -/// for possible restoration. The staging directory is removed on a best-effort basis regardless +/// Files are installed in sorted path order through [`install_file`], following the caller's +/// backup policy. The staging directory is removed on a best-effort basis regardless /// of the operation's result; a cleanup error does not replace the extraction result. /// /// # Panics @@ -365,6 +375,7 @@ async fn extract_into( archive: &Path, prefix: &Path, destination: &Path, + backup_files: bool, cancellation: &CancellationToken, ) -> Result<()> { let stage = prefix @@ -386,7 +397,7 @@ async fn extract_into( stage: stage.clone(), } })?); - install_file(&source, prefix, &relative).await?; + install_file(&source, prefix, &relative, backup_files).await?; } check_cancellation(cancellation)?; Ok::<_, Error>(()) @@ -437,6 +448,7 @@ mod tests { &root.join("missing.tar"), &root.join("prefix"), Path::new("drive_c"), + true, &cancellation, ) .await, diff --git a/src/addons/installer/mod.rs b/src/addons/installer/mod.rs index baf164a..d0723aa 100644 --- a/src/addons/installer/mod.rs +++ b/src/addons/installer/mod.rs @@ -79,8 +79,8 @@ impl InstallResource { pub(crate) enum InstallStep { /// Copies a resource file into the Wine prefix. /// - /// An existing regular destination file is backed up once alongside the destination so an - /// uninstall mode that restores files can reinstate it. + /// Standard backs up an existing regular file once for restoration during uninstall. + /// Shared layered builds disable these backups; their lower layer retains the original. Copy { /// Path intended to be relative to the resource, or empty to copy the resource itself. #[serde(default)] diff --git a/src/environment/prefix/standard.rs b/src/environment/prefix/standard.rs index d6e4489..15cd262 100644 --- a/src/environment/prefix/standard.rs +++ b/src/environment/prefix/standard.rs @@ -124,6 +124,7 @@ pub(super) async fn apply( }, &payload, resources, + true, cancellation, |_| { progress.send_replace(Some(Progress::new(Stage::Configuring))); diff --git a/src/environment/prefix/virgo/artifacts/adapter.rs b/src/environment/prefix/virgo/artifacts/adapter.rs new file mode 100644 index 0000000..fe9d45a --- /dev/null +++ b/src/environment/prefix/virgo/artifacts/adapter.rs @@ -0,0 +1,45 @@ +//! Runner initialization effects over pinned Soda, with registry changes stored as patches. + +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::{VirgoLayer, build::build, cache}; +use crate::{ + Context, + error::{Error, Result}, + runner::Runner, +}; + +pub(crate) async fn prepare_adapter( + id: Uuid, + runner: &dyn Runner, + base: &VirgoLayer, + cx: &Context, + cancellation: &CancellationToken, +) -> Result { + let destination = cx + .directories() + .data_dir() + .join("virgo/adapters") + .join(id.to_string()); + if let Some(artifact) = cache::load(&destination, Some(id)).await? { + return Ok(artifact); + } + let _build = cancellation + .run_until_cancelled(cx.artifact_build().lock()) + .await + .ok_or(Error::Cancelled)?; + if let Some(artifact) = cache::load(&destination, Some(id)).await? { + return Ok(artifact); + } + build( + id, + &destination, + runner, + base, + cx, + cancellation, + |prefix, runner| async move { runner.wineboot(&prefix, "--init").await }, + ) + .await +} diff --git a/src/environment/prefix/virgo/artifacts/build.rs b/src/environment/prefix/virgo/artifacts/build.rs new file mode 100644 index 0000000..bc5ba76 --- /dev/null +++ b/src/environment/prefix/virgo/artifacts/build.rs @@ -0,0 +1,100 @@ +//! Shared build lifecycle for filesystem and registry effects over pinned Soda. + +use std::{ + future::Future, + path::{Path, PathBuf}, +}; + +use fvs_rs::UnmountMode; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use super::super::registry; +use super::{VirgoLayer, cache, remove_dir}; +use crate::{ + Context, EnvironmentError, + environment::{prefix::FVS_BLOCK_SIZE, runtime}, + error::{Error, Result}, + runner::Runner, +}; + +/// The caller holds the build lock. The execution step receives the same runner that +/// this workflow must stop before diffing and releasing its scratch mount. +pub(super) async fn build<'a, Fut>( + id: Uuid, + destination: &Path, + runner: &'a dyn Runner, + base: &VirgoLayer, + cx: &Context, + cancellation: &CancellationToken, + work: impl FnOnce(PathBuf, &'a dyn Runner) -> Fut + Send, +) -> Result +where + Fut: Future> + Send, +{ + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + let client = cx.fvs().await?; + let stage = cx + .directories() + .data_dir() + .join("virgo/.staging") + .join(Uuid::new_v4().to_string()); + let artifact = stage.join("artifact"); + let upper = artifact.join("filesystem"); + let prefix = stage.join("prefix"); + let patches = artifact.join("registry"); + let setup = async { + async_fs::create_dir_all(&upper).await?; + async_fs::create_dir_all(&prefix).await?; + Ok::<_, Error>(()) + } + .await; + if let Err(error) = setup { + remove_dir(stage).await; + return Err(error); + } + let mount = client + .mount(&prefix, vec![base.layer.clone()], Some(&upper)) + .await?; + let executed = async { + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + work(prefix.clone(), runner).await + } + .await; + // Stop even if execution failed; retain storage when shutdown fails. + runtime::stop(runner, &prefix).await?; + let diffed = async { + executed?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + registry::write_patches(&base.registry, &prefix, &patches).await?; + client.diff_mount(&mount, true).await?; + Ok::<_, Error>(()) + } + .await; + client + .unmount(&mount, UnmountMode::Normal) + .await + .map_err(|source| EnvironmentError::Cleanup { + prefix: prefix.clone(), + source: Box::new(source.into()), + })?; + let result = async { + diffed?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + registry::exclude_hives(&upper).await?; + let repository = client.new_repository(&upper, FVS_BLOCK_SIZE).await?; + let commit = client.commit(&repository, id.to_string()).await?; + cache::publish(&artifact, destination, id, commit.state_id, cancellation).await + } + .await; + remove_dir(stage).await; + result +} diff --git a/src/environment/prefix/virgo/artifacts/cache.rs b/src/environment/prefix/virgo/artifacts/cache.rs index f314f4d..2f95491 100644 --- a/src/environment/prefix/virgo/artifacts/cache.rs +++ b/src/environment/prefix/virgo/artifacts/cache.rs @@ -1,224 +1,88 @@ -//! Shared immutable Virgo addon cache. -//! -//! Each addon UUID owns an FVS filesystem layer and a separate set of forward -//! registry patches. Registry hives are excluded from the layer so their changes -//! can be merged into each owner's writable upper directory. +//! Storage for complete immutable bases, addons, and runner adapters. -use std::{ - ops::AsyncFnOnce, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; -use super::super::registry::{registry_files, write_forward}; -use fvs_rs::{Layer, UnmountMode}; -use regdiff_rs::prelude::apply_files; +use fvs_rs::{Layer, Repository}; +use serde::{Deserialize, Serialize}; +use tokio_util::sync::CancellationToken; use uuid::Uuid; -use crate::{ - Context, - error::{Error, Result}, - runner::Runner, -}; +use super::super::{VirgoError, registry::registry_files}; +use crate::error::{Error, Result}; -use crate::environment::{VirgoError, prefix::FVS_BLOCK_SIZE}; - -/// Checks only for FVS repository metadata; [`layer`] validates its commit. -pub(crate) async fn exists(id: Uuid, context: &Context) -> Result { - let path = layer_path(id, context).join(".fvs2"); - Ok(async_fs::metadata(path) - .await - .is_ok_and(|entry| entry.is_dir())) +#[derive(Deserialize, Serialize, next_config::Config)] +#[config(version = 1)] +struct VirgoLayerManifest { + id: Uuid, + commit: String, } -/// Builds and publishes the cached filesystem layer and registry patches. -/// -/// Installation runs in a unique staging mount over the pinned Soda base. The -/// registry is diffed separately, unchanged filesystem entries are pruned by -/// FVS, and the registry hives are removed before the upper directory is -/// committed as a reusable layer. -/// -/// Existing cache entries are removed before the build. Publishing the registry -/// and filesystem destinations requires two renames and is not atomic as a pair; -/// failure may therefore leave only one destination present. Staging cleanup is -/// best-effort. -pub(crate) async fn install( - base: Layer, - item_id: Uuid, - runner: &dyn Runner, - execute: F, - context: &Context, -) -> Result<()> -where - F: for<'a> AsyncFnOnce(&'a Path) -> Result<()>, -{ - let layer_root = layer_root(context); - let registry_root = registry_root(context); - let destination = layer_root.join(item_id.to_string()); - let registry_destination = registry_root.join(item_id.to_string()); - let stage = context - .directories() - .data_dir() - .join("virgo/.staging") - .join(Uuid::new_v4().to_string()); - let upper = stage.join("upper"); - let prefix = stage.join("prefix"); - let before = stage.join("before"); - let patches = stage.join("registry"); +/// Resolved installed effects, independent of build inputs and artifact kind. +pub(crate) struct VirgoLayer { + pub(crate) id: Uuid, + pub(crate) layer: Layer, + pub(crate) registry: PathBuf, +} - let setup = async { - remove_dir_if_exists(&destination).await?; - remove_dir_if_exists(®istry_destination).await?; - for path in [&upper, &prefix, &before, &patches] { - async_fs::create_dir_all(path).await?; +impl VirgoLayerManifest { + fn resolve(self, root: &Path) -> VirgoLayer { + let repository = Repository { + repository_path: root.join("filesystem").display().to_string(), + block_size: 0, + }; + VirgoLayer { + id: self.id, + layer: Layer::from_state_id(&repository, Some(&self.commit)), + registry: root.join("registry"), } - Ok::<_, Error>(()) - } - .await; - if let Err(error) = setup { - remove_stage(stage).await; - return Err(error); } +} - let client = context.fvs().await?; - let mount = client.mount(&prefix, vec![base], Some(&upper)).await?; - let installed = async { - for (file, _) in registry_files() { - async_fs::copy(prefix.join(file), before.join(file)).await?; - } - execute(&prefix).await +/// Only an absent directory is a cache miss. +/// UUID-keyed caches check the expected ID; the fixed base directory discovers its pinned ID. +pub(super) async fn load(root: &Path, id: Option) -> Result> { + match async_fs::symlink_metadata(root).await { + Ok(entry) if entry.is_dir() => {} + Ok(_) => return Err(VirgoError::InvalidArtifact(root.to_path_buf()).into()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), } - .await; - crate::environment::runtime::stop(runner, &prefix).await?; - let diffed: Result<()> = async { - installed?; - let diff_before = before.clone(); - let diff_prefix = prefix.clone(); - let diff_patches = patches.clone(); - blocking::unblock(move || { - for (file, hive) in registry_files() { - write_forward( - &diff_before.join(file), - &diff_prefix.join(file), - &diff_patches.join(file), - hive, - )?; - } - Ok::<_, Error>(()) - }) - .await?; - client.diff_mount(&mount, true).await?; - Ok(()) + let manifest: VirgoLayerManifest = next_config::load(root.join("manifest.toml")).await?; + if id.is_some_and(|id| manifest.id != id) || manifest.commit.is_empty() { + return Err(VirgoError::InvalidArtifact(root.to_path_buf()).into()); } - .await; - client - .unmount(&mount, UnmountMode::Normal) - .await - .map_err(|source| crate::EnvironmentError::Cleanup { - prefix: prefix.clone(), - source: Box::new(source.into()), - })?; - - let result: Result<()> = async { - diffed?; - for (file, _) in registry_files() { - remove_file(&upper.join(file)).await?; - } - let client = context.fvs().await?; - let repository = client.new_repository(&upper, FVS_BLOCK_SIZE).await?; - client.commit(&repository, item_id.to_string()).await?; - - async_fs::create_dir_all(layer_root).await?; - async_fs::create_dir_all(registry_root).await?; - async_fs::rename(patches, registry_destination).await?; - async_fs::rename(upper, destination).await?; - Ok(()) - } - .await; - remove_stage(stage).await; - result -} - -/// Merges a cached addon's registry patches into prepared registry hives. -/// -/// A missing patch directory means the addon has no recorded registry effects. -pub(crate) async fn apply_registry(prefix: &Path, id: Uuid, context: &Context) -> Result<()> { - let patches = registry_path(id, context); - if !async_fs::metadata(&patches) - .await - .is_ok_and(|entry| entry.is_dir()) + if !async_fs::metadata(root.join("filesystem/.fvs2")) + .await? + .is_dir() { - return Ok(()); + return Err(VirgoError::InvalidArtifact(root.to_path_buf()).into()); } - - let apply_prefix = prefix.to_path_buf(); - blocking::unblock(move || { - for (file, hive) in registry_files() { - let path = apply_prefix.join(file); - apply_files(&path, &patches.join(file), &path, hive) - .map_err(|error| VirgoError::Registry(error.to_string()))?; + for (file, _) in registry_files() { + if !async_fs::metadata(root.join("registry").join(file)) + .await? + .is_file() + { + return Err(VirgoError::InvalidArtifact(root.to_path_buf()).into()); } - Ok(()) - }) - .await -} - -/// Resolves a cached layer and its first available commit. -/// -/// Repository metadata without a commit is treated as a corrupt cache entry. -pub(crate) async fn layer(id: Uuid, context: &Context) -> Result { - let destination = layer_path(id, context); - if !async_fs::metadata(destination.join(".fvs2")) - .await - .is_ok_and(|entry| entry.is_dir()) - { - return Err(VirgoError::CachedLayerNotFound(destination).into()); } - let client = context.fvs().await?; - let repository = client.new_repository(&destination, 0).await?; - let commit = client - .list_commits(&repository) - .await? - .into_iter() - .next() - .ok_or_else(|| VirgoError::MissingCommit { - repository: destination, - state: "HEAD".into(), - })?; - Ok(Layer::from_summary(&repository, Some(&commit))) -} - -fn layer_root(context: &Context) -> PathBuf { - context.directories().data_dir().join("virgo/layers") -} - -fn registry_root(context: &Context) -> PathBuf { - context.directories().data_dir().join("virgo/registry") -} - -fn layer_path(id: Uuid, context: &Context) -> PathBuf { - layer_root(context).join(id.to_string()) + Ok(Some(manifest.resolve(root))) } -fn registry_path(id: Uuid, context: &Context) -> PathBuf { - registry_root(context).join(id.to_string()) -} - -async fn remove_file(path: &Path) -> std::io::Result<()> { - match async_fs::remove_file(path).await { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), +/// The caller has committed the filesystem and written both registry files in staging. +/// The shared build lock covers publication; published nonempty directories are never replaced. +pub(super) async fn publish( + artifact: &Path, + destination: &Path, + id: Uuid, + commit: String, + cancellation: &CancellationToken, +) -> Result { + let manifest = VirgoLayerManifest { id, commit }; + next_config::save(artifact.join("manifest.toml"), &manifest).await?; + async_fs::create_dir_all(destination.parent().expect("artifact has a parent")).await?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); } -} - -async fn remove_dir_if_exists(path: &Path) -> std::io::Result<()> { - match async_fs::remove_dir_all(path).await { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error), - } -} - -async fn remove_stage(stage: PathBuf) { - let _ = remove_dir_if_exists(&stage).await; + async_fs::rename(artifact, destination).await?; + Ok(manifest.resolve(destination)) } diff --git a/src/environment/prefix/virgo/artifacts/mod.rs b/src/environment/prefix/virgo/artifacts/mod.rs index 8f964b2..8cec7bb 100644 --- a/src/environment/prefix/virgo/artifacts/mod.rs +++ b/src/environment/prefix/virgo/artifacts/mod.rs @@ -1,35 +1,23 @@ //! Shared immutable bases, runner adapters, and UUID-only addon caches. -pub(super) mod cache; +mod adapter; +mod build; +mod cache; mod software; +pub(crate) use adapter::prepare_adapter; +pub(crate) use cache::VirgoLayer; pub(crate) use software::prepare_addon; use super::VirgoError; use crate::environment::prefix::FVS_BLOCK_SIZE; use crate::{ - Addon, Addons, CatalogEntry, Component, Context, Release, Slot, + Addons, CatalogEntry, Component, Context, Slot, error::{Error, Result}, - runner::Runner, }; -use fvs_rs::{Layer, Repository, UnmountMode}; -use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tokio_util::sync::CancellationToken; use uuid::Uuid; -#[derive(Deserialize, Serialize)] -struct Base { - soda: Addon, - layer: Layer, -} -impl next_config::Config for Base { - const VERSION: u32 = 1; -} - -fn manifest(cx: &Context) -> PathBuf { - cx.directories().data_dir().join("virgo/base.toml") -} - fn latest_soda(entries: &[CatalogEntry]) -> Result<&CatalogEntry> { let mut latest = None; for entry in entries @@ -50,50 +38,44 @@ fn latest_soda(entries: &[CatalogEntry]) -> Result<&CatalogEntry Result>> { - addons - .component(id) - .filter(|entry| entry.slot() == Slot::Runner && entry.version() == version) - .ok_or_else(|| { - VirgoError::SodaNotDownloaded { - id, - version: version.into(), - } - .into() - }) -} - -// Caller holds the shared build mutex, including publication of the manifest. -async fn ensure_base( +/// Resolve the pinned Soda base, creating it under the shared build lock if absent. +pub(crate) async fn prepare_base( addons: &Addons, cx: &Context, cancellation: &CancellationToken, -) -> Result { - if crate::utils::exists(&manifest(cx)).await? { - return Ok(next_config::load(manifest(cx)).await?); +) -> Result { + let destination = cx.directories().data_dir().join("virgo/soda"); + if let Some(base) = cache::load(&destination, None).await? { + return Ok(base); + } + let _build = cancellation + .run_until_cancelled(cx.artifact_build().lock()) + .await + .ok_or(Error::Cancelled)?; + if let Some(base) = cache::load(&destination, None).await? { + return Ok(base); } if cancellation.is_cancelled() { return Err(Error::Cancelled); } let entries = addons.component_entries(); let selected = latest_soda(&entries)?; - let downloaded = downloaded_soda(selected.id(), selected.version(), addons)?; - let soda = Addon::from(downloaded.as_ref()); - let runner = soda.load_runner(cx.directories(), None).await?; - let root = cx.directories().data_dir().join("virgo/soda"); - let prefix = root.join("prefix"); - async_fs::create_dir_all(&root).await?; - // Never reuse an unpublished prefix: failed shutdown may have left Wine alive. - async_fs::create_dir(&prefix).await.map_err(|error| { - std::io::Error::new( - error.kind(), - format!("cannot create Soda prefix at {}: {error}", prefix.display()), - ) - })?; + let soda = addons + .component(selected.id()) + .ok_or_else(|| VirgoError::SodaNotDownloaded { + id: selected.id(), + version: selected.version().into(), + })?; + let runner = soda.addon().load_runner(cx.directories(), None).await?; + let stage = cx + .directories() + .data_dir() + .join("virgo/.staging") + .join(Uuid::new_v4().to_string()); + let artifact = stage.join("artifact"); + let prefix = artifact.join("filesystem"); + let registry = artifact.join("registry"); + async_fs::create_dir_all(&prefix).await?; let initialized = runner.wineboot(&prefix, "--init").await; // Keep storage if Wine cannot be stopped safely. crate::environment::runtime::stop(runner.as_ref(), &prefix).await?; @@ -102,6 +84,8 @@ async fn ensure_base( if cancellation.is_cancelled() { return Err(Error::Cancelled); } + // Both copies describe this same stopped prefix, before atomic publication. + super::registry::capture(&prefix, ®istry).await?; let client = cx.fvs().await?; let repository = client.new_repository(&prefix, FVS_BLOCK_SIZE).await?; let commit = client @@ -110,120 +94,18 @@ async fn ensure_base( format!("Soda {} ({})", soda.version(), soda.id()), ) .await?; - let base = Base { - soda, - layer: Layer::new(&repository, Some(&commit)), - }; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - next_config::save(manifest(cx), &base).await?; - Ok::<_, Error>(base) - } - .await; - if result.is_err() { - remove_dir(root).await; - } - result -} - -pub(crate) async fn base_layers( - runner: &dyn Runner, - runner_key: &str, - addons: &Addons, - cx: &Context, - cancellation: &CancellationToken, -) -> Result> { - let _build = cancellation - .run_until_cancelled(cx.artifact_build().lock()) - .await - .ok_or(Error::Cancelled)?; - let base = ensure_base(addons, cx, cancellation).await?; - let adapter = ensure_adapter(runner, runner_key, &base.layer, cx, cancellation).await?; - Ok(vec![base.layer, adapter]) -} - -/// Loads or creates the selected runner adapter over the pinned Soda base. -/// -/// Creation is staged over the shared base and published by renaming the -/// committed upper directory into the adapter cache. -async fn ensure_adapter( - runner: &dyn Runner, - runner_key: &str, - base: &Layer, - context: &Context, - cancellation: &CancellationToken, -) -> Result { - let root = context.directories().data_dir().join("virgo/soda/adapters"); - let destination = root.join(runner_key); - if async_fs::metadata(destination.join(".fvs2")) - .await - .is_ok_and(|entry| entry.is_dir()) - { - let client = context.fvs().await?; - let repository = client.new_repository(&destination, 0).await?; - let commit = client - .list_commits(&repository) - .await? - .into_iter() - .next() - .ok_or_else(|| VirgoError::MissingCommit { - repository: destination.clone(), - state: "HEAD".into(), - })?; - return Ok(Layer::from_summary(&repository, Some(&commit))); - } - - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - let stage = context - .directories() - .data_dir() - .join("virgo/.staging") - .join(Uuid::new_v4().to_string()); - let upper = stage.join("upper"); - let mountpoint = stage.join("prefix"); - async_fs::create_dir_all(&upper).await?; - async_fs::create_dir_all(&mountpoint).await?; - - let client = context.fvs().await?; - let mount = client - .mount(&mountpoint, vec![base.clone()], Some(&upper)) - .await?; - let initialized = runner.wineboot(&mountpoint, "--init").await; - crate::environment::runtime::stop(runner, &mountpoint).await?; - client - .unmount(&mount, UnmountMode::Normal) + cache::publish( + &artifact, + &destination, + soda.id(), + commit.state_id, + cancellation, + ) .await - .map_err(|source| crate::EnvironmentError::Cleanup { - prefix: mountpoint.clone(), - source: Box::new(source.into()), - })?; - let build = async { - initialized?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - - let client = context.fvs().await?; - let repository = client.new_repository(&upper, FVS_BLOCK_SIZE).await?; - let commit = client - .commit(&repository, format!("Runner adapter {runner_key}")) - .await?; - async_fs::create_dir_all(root).await?; - async_fs::rename(&upper, &destination).await?; - Ok::<_, Error>(commit) } .await; remove_dir(stage).await; - - let commit = build?; - let repository = Repository { - repository_path: destination.display().to_string(), - block_size: FVS_BLOCK_SIZE, - }; - Ok(Layer::new(&repository, Some(&commit))) + result } async fn remove_dir(path: PathBuf) { diff --git a/src/environment/prefix/virgo/artifacts/software.rs b/src/environment/prefix/virgo/artifacts/software.rs index e2260a9..7a4dcde 100644 --- a/src/environment/prefix/virgo/artifacts/software.rs +++ b/src/environment/prefix/virgo/artifacts/software.rs @@ -4,7 +4,7 @@ use tokio::sync::watch; use tokio_util::sync::CancellationToken; use uuid::Uuid; -use super::{cache, downloaded_soda, ensure_base}; +use super::{VirgoLayer, build::build, cache}; use crate::{ AddonError, Addons, Context, EnvVars, EnvironmentError, Progress, Slot, Stage, addons::{InstallInputs, execute}, @@ -13,56 +13,68 @@ use crate::{ pub(crate) async fn prepare_addon( id: Uuid, + base: &VirgoLayer, addons: &Addons, cx: &Context, progress: &watch::Sender>, cancellation: &CancellationToken, -) -> Result<()> { +) -> Result { + let destination = cx + .directories() + .data_dir() + .join("virgo/addons") + .join(id.to_string()); + if let Some(artifact) = cache::load(&destination, Some(id)).await? { + return Ok(artifact); + } let _build = cancellation .run_until_cancelled(cx.artifact_build().lock()) .await .ok_or(Error::Cancelled)?; - // UUID alone is the cache identity, independent of owner settings and runner. - if cache::exists(id, cx).await? { - return Ok(()); + if let Some(artifact) = cache::load(&destination, Some(id)).await? { + return Ok(artifact); } let component = addons.component(id); let dependency = addons.dependency(id); let (payload, resources) = if let Some(release) = &component { - release.validate(&release.path(cx.directories())).await?; - (release.path(cx.directories()), release.resources()) + let payload = release.path(cx.directories()); + release.validate(&payload).await?; + (payload, release.resources()) } else if let Some(release) = &dependency { - release.validate(&release.path(cx.directories())).await?; - (release.path(cx.directories()), release.resources()) + let payload = release.path(cx.directories()); + release.validate(&payload).await?; + (payload, release.resources()) } else { return Err(AddonError::NotFound(id).into()); }; - let base = ensure_base(addons, cx, cancellation).await?; - let soda = downloaded_soda(base.soda.id(), base.soda.version(), addons)?; + let soda = addons + .component(base.id) + .ok_or(AddonError::NotFound(base.id))?; let runner = soda.addon().load_runner(cx.directories(), None).await?; let winebridge = addons .latest_component(Slot::WineBridge) .ok_or(EnvironmentError::ComponentNotInstalled(Slot::WineBridge))? .path(cx.directories()); - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - let mut env_vars = EnvVars::default(); - cache::install( - base.layer, + build( id, + &destination, runner.as_ref(), - async |prefix| { + base, + cx, + cancellation, + |prefix, runner| async move { + let mut env_vars = EnvVars::default(); execute( InstallInputs { - prefix, - runner: runner.as_ref(), + prefix: &prefix, + runner, winebridge: &winebridge, env_vars: &mut env_vars, explicit_env_vars: &EnvVars::default(), }, &payload, resources, + false, cancellation, |_| { progress.send_replace(Some(Progress::new(Stage::Configuring))); @@ -70,7 +82,6 @@ pub(crate) async fn prepare_addon( ) .await }, - cx, ) .await } diff --git a/src/environment/prefix/virgo/mod.rs b/src/environment/prefix/virgo/mod.rs index c448017..c87deb8 100644 --- a/src/environment/prefix/virgo/mod.rs +++ b/src/environment/prefix/virgo/mod.rs @@ -25,24 +25,16 @@ pub(super) async fn prepare( progress: &watch::Sender>, cancellation: &CancellationToken, ) -> Result<()> { - let ids: Vec<_> = config + let base = artifacts::prepare_base(addons, cx, cancellation).await?; + let adapter = + artifacts::prepare_adapter(config.runner().id(), runner, &base, cx, cancellation).await?; + let ids = config .ordered_components() .map(crate::Addon::id) - .chain(config.dependencies.iter().map(crate::Addon::id)) - .collect(); - for id in &ids { - artifacts::prepare_addon(*id, addons, cx, progress, cancellation).await?; - } - let mut layers = artifacts::base_layers( - runner, - &config.runner().id().to_string(), - addons, - cx, - cancellation, - ) - .await?; - for id in &ids { - layers.push(artifacts::cache::layer(*id, cx).await?); + .chain(config.dependencies.iter().map(crate::Addon::id)); + let mut built = Vec::new(); + for id in ids { + built.push(artifacts::prepare_addon(id, &base, addons, cx, progress, cancellation).await?); } if cancellation.is_cancelled() { return Err(Error::Cancelled); @@ -56,8 +48,12 @@ pub(super) async fn prepare( progress, ) .await?; + let patches = std::iter::once(&adapter) + .chain(built.iter()) + .map(|artifact| artifact.registry.clone()) + .collect(); let result = async { - registry::compose(root, &layers, &ids, cx).await?; + registry::compose(root, &base.registry, patches).await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); } @@ -65,6 +61,8 @@ pub(super) async fn prepare( } .await; history::recover(result, root, &checkpoint, cx, progress).await?; + let mut layers = vec![base.layer, adapter.layer]; + layers.extend(built.into_iter().map(|addon| addon.layer)); mount(root, layers, cx).await } @@ -138,14 +136,6 @@ pub enum VirgoError { #[error("download Soda {version} ({id}) before building the Virgo base or an addon layer")] SodaNotDownloaded { id: uuid::Uuid, version: String }, - /// A required FVS commit is missing from a repository. - #[error("FVS repository {repository} has no commit {state}")] - MissingCommit { - /// Repository whose history was searched. - repository: std::path::PathBuf, - /// Requested full or abbreviated state ID. - state: String, - }, /// Virgo cannot mount a prefix over a nonempty mountpoint. #[error("mountpoint is not empty: {0}")] DirtyMountpoint(std::path::PathBuf), @@ -153,9 +143,9 @@ pub enum VirgoError { "mounted layers or writable upper differ from the selected composition at {0}; call stop() and retry" )] MountMismatch(std::path::PathBuf), - /// A cached layer required to construct the prefix is missing. - #[error("cached Virgo layer was not found: {0}")] - CachedLayerNotFound(std::path::PathBuf), + /// A published artifact has an unsupported format or incomplete installed effects. + #[error("invalid Virgo artifact: {0}")] + InvalidArtifact(std::path::PathBuf), /// Registry data could not be converted while building a Virgo layer. #[error("failed to process Virgo registry data: {0}")] Registry(String), diff --git a/src/environment/prefix/virgo/registry.rs b/src/environment/prefix/virgo/registry.rs index dc8a56f..fde46a4 100644 --- a/src/environment/prefix/virgo/registry.rs +++ b/src/environment/prefix/virgo/registry.rs @@ -1,13 +1,12 @@ //! Compose the managed registry baseline, then replay private changes over it. -use super::{VirgoError, artifacts::cache}; -use crate::{ - Context, - error::{Error, Result}, -}; -use fvs_rs::{Layer, UnmountMode}; +use super::VirgoError; +use crate::error::{Error, Result}; use regdiff_rs::prelude::{Diff, Hive, Registry, apply_files}; -use std::{fs, path::Path}; +use std::{ + fs, + path::{Path, PathBuf}, +}; use uuid::Uuid; pub(crate) fn registry_files() -> [(&'static str, Hive); 2] { @@ -28,6 +27,49 @@ pub(crate) fn write_forward(old: &Path, new: &Path, output: &Path, hive: Hive) - Ok(()) } +/// Save the initial hives alongside the stopped base filesystem before publication. +pub(super) async fn capture(prefix: &Path, before: &Path) -> Result<()> { + async_fs::create_dir_all(before).await?; + for (file, _) in registry_files() { + async_fs::copy(prefix.join(file), before.join(file)).await?; + } + Ok(()) +} + +/// Write both forward patches after Wine has stopped, including empty changes. +pub(super) async fn write_patches(before: &Path, prefix: &Path, patches: &Path) -> Result<()> { + let (before, prefix, patches) = ( + before.to_path_buf(), + prefix.to_path_buf(), + patches.to_path_buf(), + ); + blocking::unblock(move || { + fs::create_dir_all(&patches)?; + for (file, hive) in registry_files() { + write_forward( + &before.join(file), + &prefix.join(file), + &patches.join(file), + hive, + )?; + } + Ok(()) + }) + .await +} + +/// Committed artifact layers carry registry patches separately from filesystem effects. +pub(super) async fn exclude_hives(filesystem: &Path) -> Result<()> { + for (file, _) in registry_files() { + match async_fs::remove_file(filesystem.join(file)).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + fn merge_private(previous: &Path, upper: &Path, baseline: &Path, merged: &Path) -> Result<()> { fs::create_dir_all(merged)?; for (file, hive) in registry_files() { @@ -58,69 +100,43 @@ fn merge_private(previous: &Path, upper: &Path, baseline: &Path, merged: &Path) } /// The owner is stopped and checkpointed. Only managed registry files are replaced; -/// all other private files and whiteouts keep normal overlay precedence. -pub(crate) async fn compose( - root: &Path, - layers: &[Layer], - addons: &[Uuid], - cx: &Context, -) -> Result<()> { - let stage = cx - .directories() - .data_dir() - .join("virgo/.staging") - .join(Uuid::new_v4().to_string()); - let prefix = stage.join("prefix"); - let baseline = stage.join("baseline"); - async_fs::create_dir_all(&prefix).await?; - async_fs::create_dir_all(&baseline).await?; - let client = cx.fvs().await?; - let mount = client - .mount(&prefix, layers.to_vec(), None::<&Path>) - .await?; - let copied = async { +/// all other private files and whiteouts keep normal overlay precedence. Soda supplies +/// starting hives; patch paths are ordered adapter first, then selected addons. +pub(crate) async fn compose(root: &Path, initial: &Path, patches: Vec) -> Result<()> { + let stage = root.join(".staging").join(Uuid::new_v4().to_string()); + let root = root.to_path_buf(); + let initial = initial.to_path_buf(); + let scratch = stage.clone(); + let result = blocking::unblock(move || { + let baseline = scratch.join("baseline"); + fs::create_dir_all(&baseline)?; for (file, _) in registry_files() { - async_fs::copy(prefix.join(file), baseline.join(file)).await?; - } - Ok::<_, Error>(()) - } - .await; - // This scratch mount never contains the owner's upper. Release it before - // changing owner data; on failure retain the mountpoint for explicit cleanup. - client - .unmount(&mount, UnmountMode::Normal) - .await - .map_err(|source| crate::EnvironmentError::Cleanup { - prefix, - source: Box::new(source.into()), - })?; - let result = async { - copied?; - for id in addons { - cache::apply_registry(&baseline, *id, cx).await?; + fs::copy(initial.join(file), baseline.join(file))?; } - let root = root.to_path_buf(); - let stage = stage.clone(); - blocking::unblock(move || { - let previous = root.join("registry-baseline"); - let upper = root.join("upper"); - let merged = stage.join("merged"); - merge_private(&previous, &upper, &baseline, &merged)?; - for (file, _) in registry_files() { - fs::rename(merged.join(file), upper.join(file))?; - let whiteout = upper.join(format!(".wh.{file}")); - if whiteout.exists() { - fs::remove_file(whiteout)?; - } + for patch in patches { + for (file, hive) in registry_files() { + let path = baseline.join(file); + apply_files(&path, &patch.join(file), &path, hive) + .map_err(|error| VirgoError::Registry(error.to_string()))?; } - if previous.exists() { - fs::remove_dir_all(&previous)?; + } + let previous = root.join("registry-baseline"); + let upper = root.join("upper"); + let merged = scratch.join("merged"); + merge_private(&previous, &upper, &baseline, &merged)?; + for (file, _) in registry_files() { + fs::rename(merged.join(file), upper.join(file))?; + let whiteout = upper.join(format!(".wh.{file}")); + if whiteout.exists() { + fs::remove_file(whiteout)?; } - fs::rename(baseline, previous)?; - Ok::<_, Error>(()) - }) - .await - } + } + if previous.exists() { + fs::remove_dir_all(&previous)?; + } + fs::rename(baseline, previous)?; + Ok::<_, Error>(()) + }) .await; let _ = async_fs::remove_dir_all(stage).await; result From d7f5ba0e4e559c4eb9b14a23259b911f68959e4c Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Sat, 12 Sep 2026 01:09:45 +0530 Subject: [PATCH 18/24] refactor(virgo): centralize shared artifact ownership --- README.md | 7 +- src/environment/mod.rs | 2 + src/environment/prefix/mod.rs | 2 + .../prefix/virgo/artifacts/adapter.rs | 61 ++++--- .../prefix/virgo/artifacts/build.rs | 147 +++++++++-------- src/environment/prefix/virgo/artifacts/mod.rs | 149 ++++++++++-------- .../prefix/virgo/artifacts/software.rs | 143 +++++++++-------- src/environment/prefix/virgo/mod.rs | 15 +- src/utils/context.rs | 16 +- 9 files changed, 288 insertions(+), 254 deletions(-) diff --git a/README.md b/README.md index 3fa6c26..c164d65 100644 --- a/README.md +++ b/README.md @@ -173,8 +173,11 @@ identity: completed caches survive runner and settings changes. Runner adapters are built using the selected runner over the pinned base. Adapter and addon builds record registry changes as patches and exclude full hives from their committed filesystem layers. Shared construction is serialized within one -core instance. Adapter and addon builds -share the same shutdown, diff, unmount, and publication workflow. Standard +core instance. A `VirgoManager` held by the shared context owns artifact paths, +staging, and the build mutex; it has no owner configuration or retained service handles. +Constructing it does not access storage or start FVS. The context separately owns +the lazy FVS connection used by both Virgo and Standard snapshots. Adapter and addon +builds share the same shutdown, diff, unmount, and publication workflow. Standard installers continue using their owner's runner and preserve displaced files as backups. Virgo disables those backups because the lower layer retains the originals. diff --git a/src/environment/mod.rs b/src/environment/mod.rs index 29f45c1..e674b4e 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -24,6 +24,8 @@ pub use error::EnvironmentError; pub use prefix::PrefixBackend; #[cfg(feature = "fvs")] pub use prefix::VirgoError; +#[cfg(feature = "fvs")] +pub(crate) use prefix::VirgoManager; /// A temporary connection to a running execution environment. /// Successful construction establishes a WineBridge connection. The runtime may diff --git a/src/environment/prefix/mod.rs b/src/environment/prefix/mod.rs index 110705b..7d40c2e 100644 --- a/src/environment/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -7,6 +7,8 @@ mod standard; mod virgo; #[cfg(feature = "fvs")] pub use virgo::VirgoError; +#[cfg(feature = "fvs")] +pub(crate) use virgo::VirgoManager; use super::EnvironmentConfig; use crate::{Addons, Context, Progress, error::Result, runner::Runner}; diff --git a/src/environment/prefix/virgo/artifacts/adapter.rs b/src/environment/prefix/virgo/artifacts/adapter.rs index fe9d45a..0e53423 100644 --- a/src/environment/prefix/virgo/artifacts/adapter.rs +++ b/src/environment/prefix/virgo/artifacts/adapter.rs @@ -3,43 +3,42 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; -use super::{VirgoLayer, build::build, cache}; +use super::{VirgoLayer, VirgoManager, cache}; use crate::{ Context, error::{Error, Result}, runner::Runner, }; -pub(crate) async fn prepare_adapter( - id: Uuid, - runner: &dyn Runner, - base: &VirgoLayer, - cx: &Context, - cancellation: &CancellationToken, -) -> Result { - let destination = cx - .directories() - .data_dir() - .join("virgo/adapters") - .join(id.to_string()); - if let Some(artifact) = cache::load(&destination, Some(id)).await? { - return Ok(artifact); - } - let _build = cancellation - .run_until_cancelled(cx.artifact_build().lock()) +impl VirgoManager { + pub(crate) async fn prepare_adapter( + &self, + id: Uuid, + runner: &dyn Runner, + base: &VirgoLayer, + cx: &Context, + cancellation: &CancellationToken, + ) -> Result { + let destination = self.root.join("adapters").join(id.to_string()); + if let Some(artifact) = cache::load(&destination, Some(id)).await? { + return Ok(artifact); + } + let _build = cancellation + .run_until_cancelled(self.build_lock.lock()) + .await + .ok_or(Error::Cancelled)?; + if let Some(artifact) = cache::load(&destination, Some(id)).await? { + return Ok(artifact); + } + self.build( + id, + &destination, + runner, + base, + cx, + cancellation, + |prefix, runner| async move { runner.wineboot(&prefix, "--init").await }, + ) .await - .ok_or(Error::Cancelled)?; - if let Some(artifact) = cache::load(&destination, Some(id)).await? { - return Ok(artifact); } - build( - id, - &destination, - runner, - base, - cx, - cancellation, - |prefix, runner| async move { runner.wineboot(&prefix, "--init").await }, - ) - .await } diff --git a/src/environment/prefix/virgo/artifacts/build.rs b/src/environment/prefix/virgo/artifacts/build.rs index bc5ba76..14f7180 100644 --- a/src/environment/prefix/virgo/artifacts/build.rs +++ b/src/environment/prefix/virgo/artifacts/build.rs @@ -10,7 +10,7 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use super::super::registry; -use super::{VirgoLayer, cache, remove_dir}; +use super::{VirgoLayer, VirgoManager, cache, remove_dir}; use crate::{ Context, EnvironmentError, environment::{prefix::FVS_BLOCK_SIZE, runtime}, @@ -18,83 +18,82 @@ use crate::{ runner::Runner, }; -/// The caller holds the build lock. The execution step receives the same runner that -/// this workflow must stop before diffing and releasing its scratch mount. -pub(super) async fn build<'a, Fut>( - id: Uuid, - destination: &Path, - runner: &'a dyn Runner, - base: &VirgoLayer, - cx: &Context, - cancellation: &CancellationToken, - work: impl FnOnce(PathBuf, &'a dyn Runner) -> Fut + Send, -) -> Result -where - Fut: Future> + Send, -{ - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - let client = cx.fvs().await?; - let stage = cx - .directories() - .data_dir() - .join("virgo/.staging") - .join(Uuid::new_v4().to_string()); - let artifact = stage.join("artifact"); - let upper = artifact.join("filesystem"); - let prefix = stage.join("prefix"); - let patches = artifact.join("registry"); - let setup = async { - async_fs::create_dir_all(&upper).await?; - async_fs::create_dir_all(&prefix).await?; - Ok::<_, Error>(()) - } - .await; - if let Err(error) = setup { - remove_dir(stage).await; - return Err(error); - } - let mount = client - .mount(&prefix, vec![base.layer.clone()], Some(&upper)) - .await?; - let executed = async { +impl VirgoManager { + /// The caller holds the build lock. The execution step receives the same runner that + /// this workflow must stop before diffing and releasing its scratch mount. + pub(super) async fn build<'a, Fut>( + &self, + id: Uuid, + destination: &Path, + runner: &'a dyn Runner, + base: &VirgoLayer, + cx: &Context, + cancellation: &CancellationToken, + work: impl FnOnce(PathBuf, &'a dyn Runner) -> Fut + Send, + ) -> Result + where + Fut: Future> + Send, + { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - work(prefix.clone(), runner).await - } - .await; - // Stop even if execution failed; retain storage when shutdown fails. - runtime::stop(runner, &prefix).await?; - let diffed = async { - executed?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); + let client = cx.fvs().await?; + let stage = self.staging_path(); + let artifact = stage.join("artifact"); + let upper = artifact.join("filesystem"); + let prefix = stage.join("prefix"); + let patches = artifact.join("registry"); + let setup = async { + async_fs::create_dir_all(&upper).await?; + async_fs::create_dir_all(&prefix).await?; + Ok::<_, Error>(()) } - registry::write_patches(&base.registry, &prefix, &patches).await?; - client.diff_mount(&mount, true).await?; - Ok::<_, Error>(()) - } - .await; - client - .unmount(&mount, UnmountMode::Normal) - .await - .map_err(|source| EnvironmentError::Cleanup { - prefix: prefix.clone(), - source: Box::new(source.into()), - })?; - let result = async { - diffed?; - if cancellation.is_cancelled() { - return Err(Error::Cancelled); + .await; + if let Err(error) = setup { + remove_dir(stage).await; + return Err(error); + } + let mount = client + .mount(&prefix, vec![base.layer.clone()], Some(&upper)) + .await?; + let executed = async { + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + work(prefix.clone(), runner).await } - registry::exclude_hives(&upper).await?; - let repository = client.new_repository(&upper, FVS_BLOCK_SIZE).await?; - let commit = client.commit(&repository, id.to_string()).await?; - cache::publish(&artifact, destination, id, commit.state_id, cancellation).await + .await; + // Stop even if execution failed; retain storage when shutdown fails. + runtime::stop(runner, &prefix).await?; + let diffed = async { + executed?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + registry::write_patches(&base.registry, &prefix, &patches).await?; + client.diff_mount(&mount, true).await?; + Ok::<_, Error>(()) + } + .await; + client + .unmount(&mount, UnmountMode::Normal) + .await + .map_err(|source| EnvironmentError::Cleanup { + prefix: prefix.clone(), + source: Box::new(source.into()), + })?; + let result = async { + diffed?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + registry::exclude_hives(&upper).await?; + let repository = client.new_repository(&upper, FVS_BLOCK_SIZE).await?; + let commit = client.commit(&repository, id.to_string()).await?; + cache::publish(&artifact, destination, id, commit.state_id, cancellation).await + } + .await; + remove_dir(stage).await; + result } - .await; - remove_dir(stage).await; - result } diff --git a/src/environment/prefix/virgo/artifacts/mod.rs b/src/environment/prefix/virgo/artifacts/mod.rs index 8cec7bb..75d7b4e 100644 --- a/src/environment/prefix/virgo/artifacts/mod.rs +++ b/src/environment/prefix/virgo/artifacts/mod.rs @@ -4,9 +4,7 @@ mod adapter; mod build; mod cache; mod software; -pub(crate) use adapter::prepare_adapter; pub(crate) use cache::VirgoLayer; -pub(crate) use software::prepare_addon; use super::VirgoError; use crate::environment::prefix::FVS_BLOCK_SIZE; @@ -15,9 +13,30 @@ use crate::{ error::{Error, Result}, }; use std::path::PathBuf; +use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use uuid::Uuid; +/// Shared artifact storage and construction for one core context. +pub(crate) struct VirgoManager { + root: PathBuf, + build_lock: Mutex<()>, +} + +impl VirgoManager { + /// Construct without touching storage or starting FVS. + pub(crate) fn new(root: PathBuf) -> Self { + Self { + root, + build_lock: Mutex::new(()), + } + } + + fn staging_path(&self) -> PathBuf { + self.root.join(".staging").join(Uuid::new_v4().to_string()) + } +} + fn latest_soda(entries: &[CatalogEntry]) -> Result<&CatalogEntry> { let mut latest = None; for entry in entries @@ -38,74 +57,74 @@ fn latest_soda(entries: &[CatalogEntry]) -> Result<&CatalogEntry Result { - let destination = cx.directories().data_dir().join("virgo/soda"); - if let Some(base) = cache::load(&destination, None).await? { - return Ok(base); - } - let _build = cancellation - .run_until_cancelled(cx.artifact_build().lock()) - .await - .ok_or(Error::Cancelled)?; - if let Some(base) = cache::load(&destination, None).await? { - return Ok(base); - } - if cancellation.is_cancelled() { - return Err(Error::Cancelled); - } - let entries = addons.component_entries(); - let selected = latest_soda(&entries)?; - let soda = addons - .component(selected.id()) - .ok_or_else(|| VirgoError::SodaNotDownloaded { - id: selected.id(), - version: selected.version().into(), - })?; - let runner = soda.addon().load_runner(cx.directories(), None).await?; - let stage = cx - .directories() - .data_dir() - .join("virgo/.staging") - .join(Uuid::new_v4().to_string()); - let artifact = stage.join("artifact"); - let prefix = artifact.join("filesystem"); - let registry = artifact.join("registry"); - async_fs::create_dir_all(&prefix).await?; - let initialized = runner.wineboot(&prefix, "--init").await; - // Keep storage if Wine cannot be stopped safely. - crate::environment::runtime::stop(runner.as_ref(), &prefix).await?; - let result = async { - initialized?; +impl VirgoManager { + /// Resolve the pinned Soda base, creating it under the shared build lock if absent. + pub(crate) async fn prepare_base( + &self, + addons: &Addons, + cx: &Context, + cancellation: &CancellationToken, + ) -> Result { + let destination = self.root.join("soda"); + if let Some(base) = cache::load(&destination, None).await? { + return Ok(base); + } + let _build = cancellation + .run_until_cancelled(self.build_lock.lock()) + .await + .ok_or(Error::Cancelled)?; + if let Some(base) = cache::load(&destination, None).await? { + return Ok(base); + } if cancellation.is_cancelled() { return Err(Error::Cancelled); } - // Both copies describe this same stopped prefix, before atomic publication. - super::registry::capture(&prefix, ®istry).await?; - let client = cx.fvs().await?; - let repository = client.new_repository(&prefix, FVS_BLOCK_SIZE).await?; - let commit = client - .commit( - &repository, - format!("Soda {} ({})", soda.version(), soda.id()), + let entries = addons.component_entries(); + let selected = latest_soda(&entries)?; + let soda = + addons + .component(selected.id()) + .ok_or_else(|| VirgoError::SodaNotDownloaded { + id: selected.id(), + version: selected.version().into(), + })?; + let runner = soda.addon().load_runner(cx.directories(), None).await?; + let stage = self.staging_path(); + let artifact = stage.join("artifact"); + let prefix = artifact.join("filesystem"); + let registry = artifact.join("registry"); + async_fs::create_dir_all(&prefix).await?; + let initialized = runner.wineboot(&prefix, "--init").await; + // Keep storage if Wine cannot be stopped safely. + crate::environment::runtime::stop(runner.as_ref(), &prefix).await?; + let result = async { + initialized?; + if cancellation.is_cancelled() { + return Err(Error::Cancelled); + } + // Both copies describe this same stopped prefix, before atomic publication. + super::registry::capture(&prefix, ®istry).await?; + let client = cx.fvs().await?; + let repository = client.new_repository(&prefix, FVS_BLOCK_SIZE).await?; + let commit = client + .commit( + &repository, + format!("Soda {} ({})", soda.version(), soda.id()), + ) + .await?; + cache::publish( + &artifact, + &destination, + soda.id(), + commit.state_id, + cancellation, ) - .await?; - cache::publish( - &artifact, - &destination, - soda.id(), - commit.state_id, - cancellation, - ) - .await + .await + } + .await; + remove_dir(stage).await; + result } - .await; - remove_dir(stage).await; - result } async fn remove_dir(path: PathBuf) { diff --git a/src/environment/prefix/virgo/artifacts/software.rs b/src/environment/prefix/virgo/artifacts/software.rs index 7a4dcde..1801f3b 100644 --- a/src/environment/prefix/virgo/artifacts/software.rs +++ b/src/environment/prefix/virgo/artifacts/software.rs @@ -4,84 +4,83 @@ use tokio::sync::watch; use tokio_util::sync::CancellationToken; use uuid::Uuid; -use super::{VirgoLayer, build::build, cache}; +use super::{VirgoLayer, VirgoManager, cache}; use crate::{ AddonError, Addons, Context, EnvVars, EnvironmentError, Progress, Slot, Stage, addons::{InstallInputs, execute}, error::{Error, Result}, }; -pub(crate) async fn prepare_addon( - id: Uuid, - base: &VirgoLayer, - addons: &Addons, - cx: &Context, - progress: &watch::Sender>, - cancellation: &CancellationToken, -) -> Result { - let destination = cx - .directories() - .data_dir() - .join("virgo/addons") - .join(id.to_string()); - if let Some(artifact) = cache::load(&destination, Some(id)).await? { - return Ok(artifact); - } - let _build = cancellation - .run_until_cancelled(cx.artifact_build().lock()) +impl VirgoManager { + pub(crate) async fn prepare_addon( + &self, + id: Uuid, + base: &VirgoLayer, + addons: &Addons, + cx: &Context, + progress: &watch::Sender>, + cancellation: &CancellationToken, + ) -> Result { + let destination = self.root.join("addons").join(id.to_string()); + if let Some(artifact) = cache::load(&destination, Some(id)).await? { + return Ok(artifact); + } + let _build = cancellation + .run_until_cancelled(self.build_lock.lock()) + .await + .ok_or(Error::Cancelled)?; + if let Some(artifact) = cache::load(&destination, Some(id)).await? { + return Ok(artifact); + } + let component = addons.component(id); + let dependency = addons.dependency(id); + let (payload, resources) = if let Some(release) = &component { + let payload = release.path(cx.directories()); + release.validate(&payload).await?; + (payload, release.resources()) + } else if let Some(release) = &dependency { + let payload = release.path(cx.directories()); + release.validate(&payload).await?; + (payload, release.resources()) + } else { + return Err(AddonError::NotFound(id).into()); + }; + let soda = addons + .component(base.id) + .ok_or(AddonError::NotFound(base.id))?; + let runner = soda.addon().load_runner(cx.directories(), None).await?; + let winebridge = addons + .latest_component(Slot::WineBridge) + .ok_or(EnvironmentError::ComponentNotInstalled(Slot::WineBridge))? + .path(cx.directories()); + self.build( + id, + &destination, + runner.as_ref(), + base, + cx, + cancellation, + |prefix, runner| async move { + let mut env_vars = EnvVars::default(); + execute( + InstallInputs { + prefix: &prefix, + runner, + winebridge: &winebridge, + env_vars: &mut env_vars, + explicit_env_vars: &EnvVars::default(), + }, + &payload, + resources, + false, + cancellation, + |_| { + progress.send_replace(Some(Progress::new(Stage::Configuring))); + }, + ) + .await + }, + ) .await - .ok_or(Error::Cancelled)?; - if let Some(artifact) = cache::load(&destination, Some(id)).await? { - return Ok(artifact); } - let component = addons.component(id); - let dependency = addons.dependency(id); - let (payload, resources) = if let Some(release) = &component { - let payload = release.path(cx.directories()); - release.validate(&payload).await?; - (payload, release.resources()) - } else if let Some(release) = &dependency { - let payload = release.path(cx.directories()); - release.validate(&payload).await?; - (payload, release.resources()) - } else { - return Err(AddonError::NotFound(id).into()); - }; - let soda = addons - .component(base.id) - .ok_or(AddonError::NotFound(base.id))?; - let runner = soda.addon().load_runner(cx.directories(), None).await?; - let winebridge = addons - .latest_component(Slot::WineBridge) - .ok_or(EnvironmentError::ComponentNotInstalled(Slot::WineBridge))? - .path(cx.directories()); - build( - id, - &destination, - runner.as_ref(), - base, - cx, - cancellation, - |prefix, runner| async move { - let mut env_vars = EnvVars::default(); - execute( - InstallInputs { - prefix: &prefix, - runner, - winebridge: &winebridge, - env_vars: &mut env_vars, - explicit_env_vars: &EnvVars::default(), - }, - &payload, - resources, - false, - cancellation, - |_| { - progress.send_replace(Some(Progress::new(Stage::Configuring))); - }, - ) - .await - }, - ) - .await } diff --git a/src/environment/prefix/virgo/mod.rs b/src/environment/prefix/virgo/mod.rs index c87deb8..b315326 100644 --- a/src/environment/prefix/virgo/mod.rs +++ b/src/environment/prefix/virgo/mod.rs @@ -2,6 +2,7 @@ mod artifacts; mod registry; +pub(crate) use artifacts::VirgoManager; use super::super::{EnvironmentConfig, history}; use crate::{ @@ -25,16 +26,22 @@ pub(super) async fn prepare( progress: &watch::Sender>, cancellation: &CancellationToken, ) -> Result<()> { - let base = artifacts::prepare_base(addons, cx, cancellation).await?; - let adapter = - artifacts::prepare_adapter(config.runner().id(), runner, &base, cx, cancellation).await?; + let base = cx.virgo().prepare_base(addons, cx, cancellation).await?; + let adapter = cx + .virgo() + .prepare_adapter(config.runner().id(), runner, &base, cx, cancellation) + .await?; let ids = config .ordered_components() .map(crate::Addon::id) .chain(config.dependencies.iter().map(crate::Addon::id)); let mut built = Vec::new(); for id in ids { - built.push(artifacts::prepare_addon(id, &base, addons, cx, progress, cancellation).await?); + built.push( + cx.virgo() + .prepare_addon(id, &base, addons, cx, progress, cancellation) + .await?, + ); } if cancellation.is_cancelled() { return Err(Error::Cancelled); diff --git a/src/utils/context.rs b/src/utils/context.rs index 31a5e2c..4b5c898 100644 --- a/src/utils/context.rs +++ b/src/utils/context.rs @@ -3,7 +3,11 @@ use download_manager::manager::{DownloadManager, DownloadManagerConfig}; use http_client::HttpClient; use std::{path::PathBuf, sync::Arc}; #[cfg(feature = "fvs")] -use {crate::utils::absolute_path, fvs_rs::Fvs2dClient, tokio::sync::OnceCell}; +use { + crate::{environment::VirgoManager, utils::absolute_path}, + fvs_rs::Fvs2dClient, + tokio::sync::OnceCell, +}; struct ContextInner { directories: Directories, @@ -14,7 +18,7 @@ struct ContextInner { #[cfg(feature = "fvs")] fvs: OnceCell, #[cfg(feature = "fvs")] - artifact_build: tokio::sync::Mutex<()>, + virgo: VirgoManager, } #[derive(Clone)] @@ -33,6 +37,8 @@ impl Context { DownloadManagerConfig::default(), )?); Ok(Self(Arc::new(ContextInner { + #[cfg(feature = "fvs")] + virgo: VirgoManager::new(directories.data_dir().join("virgo")), directories, http_client, downloader, @@ -43,8 +49,6 @@ impl Context { .unwrap_or_else(|| PathBuf::from("fvs2d")), #[cfg(feature = "fvs")] fvs: OnceCell::new(), - #[cfg(feature = "fvs")] - artifact_build: tokio::sync::Mutex::new(()), }))) } @@ -72,8 +76,8 @@ impl Context { } #[cfg(feature = "fvs")] - pub(crate) fn artifact_build(&self) -> &tokio::sync::Mutex<()> { - &self.0.artifact_build + pub(crate) fn virgo(&self) -> &VirgoManager { + &self.0.virgo } #[cfg(feature = "fvs")] From 33cfa47ada6f420ac491d47d5eb6c9a7db26b18c Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Sat, 12 Sep 2026 01:11:35 +0530 Subject: [PATCH 19/24] refactor(virgo): separate resolution from owner preparation --- README.md | 10 +++-- .../prefix/virgo/artifacts/adapter.rs | 2 +- src/environment/prefix/virgo/artifacts/mod.rs | 41 +++++++++++++++++-- .../prefix/virgo/artifacts/software.rs | 2 +- src/environment/prefix/virgo/mod.rs | 25 +++-------- 5 files changed, 52 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index c164d65..7cafe37 100644 --- a/README.md +++ b/README.md @@ -182,10 +182,12 @@ installers continue using their owner's runner and preserve displaced files as backups. Virgo disables those backups because the lower layer retains the originals. Virgo composition is always Soda base → selected runner adapter → components in -slot order → dependencies in persisted order → private writable upper. Preparation -resolves each selected addon's immutable layer by UUID and keeps the resulting -stack local to that operation. There is no second persisted list of selections or -layers. The shared base remains pinned and completed UUID caches remain immutable. +slot order → dependencies in persisted order → private writable upper. The manager +resolves a temporary `VirgoComposition` containing the base and ordered overlays +without accessing owner data. Owner preparation consumes that same ordering for +registry patches and filesystem layers, after resolution has completed. There is +no second persisted list of selections or layers. The shared base remains pinned +and completed UUID caches remain immutable. Each preparation copies the base's published initial hives, then applies adapter patches, addon patches in selection order, and private changes diff --git a/src/environment/prefix/virgo/artifacts/adapter.rs b/src/environment/prefix/virgo/artifacts/adapter.rs index 0e53423..45663cc 100644 --- a/src/environment/prefix/virgo/artifacts/adapter.rs +++ b/src/environment/prefix/virgo/artifacts/adapter.rs @@ -11,7 +11,7 @@ use crate::{ }; impl VirgoManager { - pub(crate) async fn prepare_adapter( + pub(super) async fn prepare_adapter( &self, id: Uuid, runner: &dyn Runner, diff --git a/src/environment/prefix/virgo/artifacts/mod.rs b/src/environment/prefix/virgo/artifacts/mod.rs index 75d7b4e..8d37b52 100644 --- a/src/environment/prefix/virgo/artifacts/mod.rs +++ b/src/environment/prefix/virgo/artifacts/mod.rs @@ -9,11 +9,12 @@ pub(crate) use cache::VirgoLayer; use super::VirgoError; use crate::environment::prefix::FVS_BLOCK_SIZE; use crate::{ - Addons, CatalogEntry, Component, Context, Slot, + Addons, CatalogEntry, Component, Context, EnvironmentConfig, Progress, Slot, error::{Error, Result}, + runner::Runner, }; use std::path::PathBuf; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, watch}; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -23,6 +24,12 @@ pub(crate) struct VirgoManager { build_lock: Mutex<()>, } +/// A resolved base followed by adapter and addon effects, shared by registry and mount assembly. +pub(super) struct VirgoComposition { + pub(super) base: VirgoLayer, + pub(super) overlays: Vec, +} + impl VirgoManager { /// Construct without touching storage or starting FVS. pub(crate) fn new(root: PathBuf) -> Self { @@ -32,6 +39,34 @@ impl VirgoManager { } } + /// Resolve shared effects without reading or mutating an owner's directory. + pub(super) async fn resolve( + &self, + config: &EnvironmentConfig, + runner: &dyn Runner, + cx: &Context, + addons: &Addons, + progress: &watch::Sender>, + cancellation: &CancellationToken, + ) -> Result { + let base = self.prepare_base(addons, cx, cancellation).await?; + let adapter = self + .prepare_adapter(config.runner().id(), runner, &base, cx, cancellation) + .await?; + let mut overlays = vec![adapter]; + let ids = config + .ordered_components() + .map(crate::Addon::id) + .chain(config.dependencies.iter().map(crate::Addon::id)); + for id in ids { + overlays.push( + self.prepare_addon(id, &base, addons, cx, progress, cancellation) + .await?, + ); + } + Ok(VirgoComposition { base, overlays }) + } + fn staging_path(&self) -> PathBuf { self.root.join(".staging").join(Uuid::new_v4().to_string()) } @@ -59,7 +94,7 @@ fn latest_soda(entries: &[CatalogEntry]) -> Result<&CatalogEntry>, cancellation: &CancellationToken, ) -> Result<()> { - let base = cx.virgo().prepare_base(addons, cx, cancellation).await?; - let adapter = cx + let artifacts::VirgoComposition { base, overlays } = cx .virgo() - .prepare_adapter(config.runner().id(), runner, &base, cx, cancellation) + .resolve(config, runner, cx, addons, progress, cancellation) .await?; - let ids = config - .ordered_components() - .map(crate::Addon::id) - .chain(config.dependencies.iter().map(crate::Addon::id)); - let mut built = Vec::new(); - for id in ids { - built.push( - cx.virgo() - .prepare_addon(id, &base, addons, cx, progress, cancellation) - .await?, - ); - } if cancellation.is_cancelled() { return Err(Error::Cancelled); } @@ -55,8 +42,8 @@ pub(super) async fn prepare( progress, ) .await?; - let patches = std::iter::once(&adapter) - .chain(built.iter()) + let patches = overlays + .iter() .map(|artifact| artifact.registry.clone()) .collect(); let result = async { @@ -68,8 +55,8 @@ pub(super) async fn prepare( } .await; history::recover(result, root, &checkpoint, cx, progress).await?; - let mut layers = vec![base.layer, adapter.layer]; - layers.extend(built.into_iter().map(|addon| addon.layer)); + let mut layers = vec![base.layer]; + layers.extend(overlays.into_iter().map(|artifact| artifact.layer)); mount(root, layers, cx).await } From 102f02e3cef8275ee030169b887e093420800aa4 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Sat, 12 Sep 2026 01:28:02 +0530 Subject: [PATCH 20/24] refactor(virgo): bind shared manager to core services --- README.md | 15 +++-- src/bottle/manager.rs | 55 +++++++++++++++++-- src/bottle/software.rs | 6 +- src/bottle/state.rs | 23 +++++++- src/bottle/tests.rs | 25 ++++++++- src/core.rs | 13 ++++- src/environment/mod.rs | 5 +- src/environment/prefix/mod.rs | 6 +- .../prefix/virgo/artifacts/adapter.rs | 5 +- .../prefix/virgo/artifacts/build.rs | 5 +- src/environment/prefix/virgo/artifacts/mod.rs | 46 +++++++++------- .../prefix/virgo/artifacts/software.rs | 28 +++++----- src/environment/prefix/virgo/mod.rs | 43 +++------------ src/utils/context.rs | 15 +---- 14 files changed, 177 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 7cafe37..50b03f2 100644 --- a/README.md +++ b/README.md @@ -61,11 +61,11 @@ Backends stop their initialization and installer processes through shared runtim helpers, without invoking owner lifecycle operations. Process inspection and group kill attach to an existing runtime without starting -Wine or inspecting FVS mounts. Startup still checks existing Virgo mounts against -resolved layers and the private upper when preparing storage. Dropping handles leaves -Wine running. Explicit `stop()` waits +Wine or inspecting FVS mounts. Successful attachment bypasses preparation. Otherwise, +startup stops Wine and releases existing storage before preparing and mounting the +selected composition. Dropping handles leaves Wine running. Explicit `stop()` waits for wineserver before unmounting, even when WineBridge cannot be reached. -Unreachable discovery and mismatched mounts require `stop()` before retrying. +Unreachable discovery requires `stop()` before retrying. Initialization and recipes run without game wrappers. Cancellation finishes cleanup before returning; failed shutdown retains storage. Temporary cache failures can @@ -173,8 +173,11 @@ identity: completed caches survive runner and settings changes. Runner adapters are built using the selected runner over the pinned base. Adapter and addon builds record registry changes as patches and exclude full hives from their committed filesystem layers. Shared construction is serialized within one -core instance. A `VirgoManager` held by the shared context owns artifact paths, -staging, and the build mutex; it has no owner configuration or retained service handles. +core instance. `Bottles::open` constructs one shared `VirgoManager` and passes it to +the bottle manager and bottle handles. It retains `Context` and `Addons`, deriving +artifact paths from that context and resolving sources through that addon manager. +It owns staging and the build mutex, and retains no owner configuration. +The context does not retain the manager, so its addon handle creates no ownership cycle. Constructing it does not access storage or start FVS. The context separately owns the lazy FVS connection used by both Virgo and Standard snapshots. Adapter and addon builds share the same shutdown, diff, unmount, and publication workflow. Standard diff --git a/src/bottle/manager.rs b/src/bottle/manager.rs index 5c412fa..0eb49ab 100644 --- a/src/bottle/manager.rs +++ b/src/bottle/manager.rs @@ -1,5 +1,8 @@ //! Collection lifecycle and discovery for library-managed bottles. +#[cfg(feature = "fvs")] +use crate::environment::VirgoManager; + use std::{ collections::{HashMap, HashSet}, hash::{Hash, Hasher}, @@ -108,6 +111,8 @@ impl BottleRegistry { pub struct BottleManager { pub(super) context: Context, pub(super) addons: Addons, + #[cfg(feature = "fvs")] + virgo: Arc, registry: Arc, } @@ -120,18 +125,33 @@ impl Hash for BottleManager { } impl BottleManager { - pub(crate) fn new(context: Context, addons: Addons) -> Self { + pub(crate) fn new( + context: Context, + addons: Addons, + #[cfg(feature = "fvs")] virgo: Arc, + ) -> Self { Self { context, addons, + #[cfg(feature = "fvs")] + virgo, registry: Arc::new(BottleRegistry::new()), } } /// Populates the shared registry, skipping unreadable bottle configuration /// with a warning so one corrupt bottle does not prevent startup. - pub(crate) async fn load(context: Context, addons: Addons) -> Result { - let manager = Self::new(context, addons); + pub(crate) async fn load( + context: Context, + addons: Addons, + #[cfg(feature = "fvs")] virgo: Arc, + ) -> Result { + let manager = Self::new( + context, + addons, + #[cfg(feature = "fvs")] + virgo, + ); let bottles = manager.load_bottles().await?; manager.registry.replace(bottles); Ok(manager) @@ -166,6 +186,8 @@ impl BottleManager { let name = name.into(); let cx = self.context.clone(); let addons = self.addons.clone(); + #[cfg(feature = "fvs")] + let virgo = self.virgo.clone(); let registry = self.registry.clone(); Operation::new(move |progress, cancellation| async move { progress.send_replace(Some(Progress::new(Stage::Preparing))); @@ -178,7 +200,16 @@ impl BottleManager { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - let bottle = Bottle::new(id, name, config, cx.clone(), addons.clone()).await?; + let bottle = Bottle::new( + id, + name, + config, + cx.clone(), + addons.clone(), + #[cfg(feature = "fvs")] + virgo.clone(), + ) + .await?; progress.send_replace(Some(Progress::new(Stage::Configuring))); if cancellation.is_cancelled() { return Err(Error::Cancelled); @@ -264,7 +295,13 @@ impl BottleManager { } .into()); } - let bottle = Bottle::from_state(state, self.context.clone(), self.addons.clone())?; + let bottle = Bottle::from_state( + state, + self.context.clone(), + self.addons.clone(), + #[cfg(feature = "fvs")] + self.virgo.clone(), + )?; Ok(self.registry.intern(bottle)) } @@ -342,7 +379,13 @@ impl BottleManager { for path in paths { match next_config::load::(path).await { Ok(state) => { - match Bottle::from_state(state, self.context.clone(), self.addons.clone()) { + match Bottle::from_state( + state, + self.context.clone(), + self.addons.clone(), + #[cfg(feature = "fvs")] + self.virgo.clone(), + ) { Ok(bottle) => bottles.push(bottle), Err(error) => { tracing::warn!("skipping bottle with invalid runtime: {error}") diff --git a/src/bottle/software.rs b/src/bottle/software.rs index 3128beb..00910e4 100644 --- a/src/bottle/software.rs +++ b/src/bottle/software.rs @@ -53,7 +53,8 @@ impl Bottle { &state.environment, &bottle.0.cx.directories().bottle(state.id), &bottle.0.cx, - &bottle.0.addons, + #[cfg(feature = "fvs")] + &bottle.0.virgo, &progress, &cancellation, ) @@ -165,7 +166,8 @@ impl Bottle { &state.environment, &bottle.0.cx.directories().bottle(state.id), &bottle.0.cx, - &bottle.0.addons, + #[cfg(feature = "fvs")] + &bottle.0.virgo, &progress, &cancellation, ) diff --git a/src/bottle/state.rs b/src/bottle/state.rs index 6aab737..929d235 100644 --- a/src/bottle/state.rs +++ b/src/bottle/state.rs @@ -1,5 +1,8 @@ //! Persisted bottle state and the shared bottle handle. +#[cfg(feature = "fvs")] +use crate::environment::VirgoManager; + use std::{ collections::HashMap, hash::{Hash, Hasher}, @@ -77,6 +80,8 @@ pub(crate) struct BottleInner { pub(crate) cx: Context, /// Shared addon registry scoped to the owning manager. pub(crate) addons: Addons, + #[cfg(feature = "fvs")] + pub(crate) virgo: Arc, } /// A live, shared handle to one bottle. @@ -110,6 +115,7 @@ impl Bottle { environment: EnvironmentConfig, context: Context, addons: Addons, + #[cfg(feature = "fvs")] virgo: Arc, ) -> Result { let state = BottleState { id, @@ -117,13 +123,24 @@ impl Bottle { environment, programs: HashMap::new(), }; - let bottle = Self::from_state(state, context, addons)?; + let bottle = Self::from_state( + state, + context, + addons, + #[cfg(feature = "fvs")] + virgo, + )?; bottle.save().await?; Ok(bottle) } /// Reconstructs a live handle after validating its addon requirements. - pub(crate) fn from_state(state: BottleState, cx: Context, addons: Addons) -> Result { + pub(crate) fn from_state( + state: BottleState, + cx: Context, + addons: Addons, + #[cfg(feature = "fvs")] virgo: Arc, + ) -> Result { state.environment.validate_requirements()?; let id = state.id; let (published, _) = watch::channel(Some(Arc::new(state))); @@ -133,6 +150,8 @@ impl Bottle { control: Mutex::new(()), cx, addons, + #[cfg(feature = "fvs")] + virgo, }))) } diff --git a/src/bottle/tests.rs b/src/bottle/tests.rs index aa37c8c..7773d16 100644 --- a/src/bottle/tests.rs +++ b/src/bottle/tests.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "fvs")] +use crate::environment::VirgoManager; + use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -30,6 +33,8 @@ async fn deleted_bottle() -> (Bottle, Directories) { published, control: Mutex::new(()), id: uuid::Uuid::new_v4(), + #[cfg(feature = "fvs")] + virgo: Arc::new(VirgoManager::new(context.clone(), addons.clone())), cx: context, addons, })); @@ -102,7 +107,16 @@ fn load_skips_corrupt_bottles() { ) .unwrap(); let addons = Addons::load(context.clone(), None, None).await.unwrap(); - let manager = BottleManager::load(context, addons).await.unwrap(); + #[cfg(feature = "fvs")] + let virgo = Arc::new(VirgoManager::new(context.clone(), addons.clone())); + let manager = BottleManager::load( + context, + addons, + #[cfg(feature = "fvs")] + virgo, + ) + .await + .unwrap(); assert!(manager.list().is_empty()); std::fs::remove_dir_all(directories.data_dir()).unwrap(); @@ -170,7 +184,14 @@ fn create_reports_all_missing_runtime_addons_before_creating_files() { addons.remove_component(unknown).await, Err(Error::Addon(AddonError::NotFound(id))) if id == unknown )); - let manager = BottleManager::new(context, addons); + #[cfg(feature = "fvs")] + let virgo = Arc::new(VirgoManager::new(context.clone(), addons.clone())); + let manager = BottleManager::new( + context, + addons, + #[cfg(feature = "fvs")] + virgo, + ); let error = match manager .create("test", PrefixBackend::Standard, runner_id) diff --git a/src/core.rs b/src/core.rs index 63a641e..97eeb3d 100644 --- a/src/core.rs +++ b/src/core.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "fvs")] +use crate::environment::VirgoManager; + #[cfg(feature = "fvs")] use std::path::PathBuf; use std::sync::Arc; @@ -43,7 +46,15 @@ impl Bottles { Arc::new(ReqwestClient::new().map_err(download_manager::error::Error::from)?); let context = Context::new(directories, http_client, fvs2d)?; let addons = Addons::load(context.clone(), component_catalog, dependency_catalog).await?; - let bottles = BottleManager::load(context.clone(), addons.clone()).await?; + #[cfg(feature = "fvs")] + let virgo = Arc::new(VirgoManager::new(context.clone(), addons.clone())); + let bottles = BottleManager::load( + context.clone(), + addons.clone(), + #[cfg(feature = "fvs")] + virgo, + ) + .await?; let library = Library::new(bottles.clone(), profiles.clone(), plugins.clone()); Ok(Self { diff --git a/src/environment/mod.rs b/src/environment/mod.rs index e674b4e..fc43e2b 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -51,7 +51,7 @@ impl Environment { config: &EnvironmentConfig, root: &Path, cx: &Context, - addons: &Addons, + #[cfg(feature = "fvs")] virgo: &VirgoManager, progress: &watch::Sender>, cancellation: &CancellationToken, ) -> Result { @@ -80,7 +80,8 @@ impl Environment { runner.as_ref(), root, cx, - addons, + #[cfg(feature = "fvs")] + virgo, progress, cancellation, ) diff --git a/src/environment/prefix/mod.rs b/src/environment/prefix/mod.rs index 7d40c2e..98adc36 100644 --- a/src/environment/prefix/mod.rs +++ b/src/environment/prefix/mod.rs @@ -97,17 +97,17 @@ impl PrefixBackend { runner: &dyn Runner, root: &Path, cx: &Context, - addons: &Addons, + #[cfg(feature = "fvs")] virgo: &VirgoManager, progress: &watch::Sender>, cancellation: &CancellationToken, ) -> Result<()> { #[cfg(not(feature = "fvs"))] - let _ = (config, runner, root, cx, addons, progress, cancellation); + let _ = (config, runner, root, cx, progress, cancellation); match self { Self::Standard => Ok(()), #[cfg(feature = "fvs")] Self::Virgo => { - virgo::prepare(config, runner, root, cx, addons, progress, cancellation).await + virgo::prepare(config, runner, root, cx, virgo, progress, cancellation).await } } } diff --git a/src/environment/prefix/virgo/artifacts/adapter.rs b/src/environment/prefix/virgo/artifacts/adapter.rs index 45663cc..dce42bc 100644 --- a/src/environment/prefix/virgo/artifacts/adapter.rs +++ b/src/environment/prefix/virgo/artifacts/adapter.rs @@ -5,7 +5,6 @@ use uuid::Uuid; use super::{VirgoLayer, VirgoManager, cache}; use crate::{ - Context, error::{Error, Result}, runner::Runner, }; @@ -16,10 +15,9 @@ impl VirgoManager { id: Uuid, runner: &dyn Runner, base: &VirgoLayer, - cx: &Context, cancellation: &CancellationToken, ) -> Result { - let destination = self.root.join("adapters").join(id.to_string()); + let destination = self.root().join("adapters").join(id.to_string()); if let Some(artifact) = cache::load(&destination, Some(id)).await? { return Ok(artifact); } @@ -35,7 +33,6 @@ impl VirgoManager { &destination, runner, base, - cx, cancellation, |prefix, runner| async move { runner.wineboot(&prefix, "--init").await }, ) diff --git a/src/environment/prefix/virgo/artifacts/build.rs b/src/environment/prefix/virgo/artifacts/build.rs index 14f7180..4339075 100644 --- a/src/environment/prefix/virgo/artifacts/build.rs +++ b/src/environment/prefix/virgo/artifacts/build.rs @@ -12,7 +12,7 @@ use uuid::Uuid; use super::super::registry; use super::{VirgoLayer, VirgoManager, cache, remove_dir}; use crate::{ - Context, EnvironmentError, + EnvironmentError, environment::{prefix::FVS_BLOCK_SIZE, runtime}, error::{Error, Result}, runner::Runner, @@ -27,7 +27,6 @@ impl VirgoManager { destination: &Path, runner: &'a dyn Runner, base: &VirgoLayer, - cx: &Context, cancellation: &CancellationToken, work: impl FnOnce(PathBuf, &'a dyn Runner) -> Fut + Send, ) -> Result @@ -37,7 +36,7 @@ impl VirgoManager { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - let client = cx.fvs().await?; + let client = self.cx.fvs().await?; let stage = self.staging_path(); let artifact = stage.join("artifact"); let upper = artifact.join("filesystem"); diff --git a/src/environment/prefix/virgo/artifacts/mod.rs b/src/environment/prefix/virgo/artifacts/mod.rs index 8d37b52..0b0dd9c 100644 --- a/src/environment/prefix/virgo/artifacts/mod.rs +++ b/src/environment/prefix/virgo/artifacts/mod.rs @@ -18,9 +18,10 @@ use tokio::sync::{Mutex, watch}; use tokio_util::sync::CancellationToken; use uuid::Uuid; -/// Shared artifact storage and construction for one core context. +/// Shared artifact storage and construction for one core instance. pub(crate) struct VirgoManager { - root: PathBuf, + cx: Context, + addons: Addons, build_lock: Mutex<()>, } @@ -32,9 +33,10 @@ pub(super) struct VirgoComposition { impl VirgoManager { /// Construct without touching storage or starting FVS. - pub(crate) fn new(root: PathBuf) -> Self { + pub(crate) fn new(cx: Context, addons: Addons) -> Self { Self { - root, + cx, + addons, build_lock: Mutex::new(()), } } @@ -44,14 +46,12 @@ impl VirgoManager { &self, config: &EnvironmentConfig, runner: &dyn Runner, - cx: &Context, - addons: &Addons, progress: &watch::Sender>, cancellation: &CancellationToken, ) -> Result { - let base = self.prepare_base(addons, cx, cancellation).await?; + let base = self.prepare_base(cancellation).await?; let adapter = self - .prepare_adapter(config.runner().id(), runner, &base, cx, cancellation) + .prepare_adapter(config.runner().id(), runner, &base, cancellation) .await?; let mut overlays = vec![adapter]; let ids = config @@ -60,15 +60,21 @@ impl VirgoManager { .chain(config.dependencies.iter().map(crate::Addon::id)); for id in ids { overlays.push( - self.prepare_addon(id, &base, addons, cx, progress, cancellation) + self.prepare_addon(id, &base, progress, cancellation) .await?, ); } Ok(VirgoComposition { base, overlays }) } + fn root(&self) -> PathBuf { + self.cx.directories().data_dir().join("virgo") + } + fn staging_path(&self) -> PathBuf { - self.root.join(".staging").join(Uuid::new_v4().to_string()) + self.root() + .join(".staging") + .join(Uuid::new_v4().to_string()) } } @@ -94,13 +100,8 @@ fn latest_soda(entries: &[CatalogEntry]) -> Result<&CatalogEntry Result { - let destination = self.root.join("soda"); + async fn prepare_base(&self, cancellation: &CancellationToken) -> Result { + let destination = self.root().join("soda"); if let Some(base) = cache::load(&destination, None).await? { return Ok(base); } @@ -114,16 +115,19 @@ impl VirgoManager { if cancellation.is_cancelled() { return Err(Error::Cancelled); } - let entries = addons.component_entries(); + let entries = self.addons.component_entries(); let selected = latest_soda(&entries)?; let soda = - addons + self.addons .component(selected.id()) .ok_or_else(|| VirgoError::SodaNotDownloaded { id: selected.id(), version: selected.version().into(), })?; - let runner = soda.addon().load_runner(cx.directories(), None).await?; + let runner = soda + .addon() + .load_runner(self.cx.directories(), None) + .await?; let stage = self.staging_path(); let artifact = stage.join("artifact"); let prefix = artifact.join("filesystem"); @@ -139,7 +143,7 @@ impl VirgoManager { } // Both copies describe this same stopped prefix, before atomic publication. super::registry::capture(&prefix, ®istry).await?; - let client = cx.fvs().await?; + let client = self.cx.fvs().await?; let repository = client.new_repository(&prefix, FVS_BLOCK_SIZE).await?; let commit = client .commit( diff --git a/src/environment/prefix/virgo/artifacts/software.rs b/src/environment/prefix/virgo/artifacts/software.rs index 67c6fc8..3e35d05 100644 --- a/src/environment/prefix/virgo/artifacts/software.rs +++ b/src/environment/prefix/virgo/artifacts/software.rs @@ -6,7 +6,7 @@ use uuid::Uuid; use super::{VirgoLayer, VirgoManager, cache}; use crate::{ - AddonError, Addons, Context, EnvVars, EnvironmentError, Progress, Slot, Stage, + AddonError, EnvVars, EnvironmentError, Progress, Slot, Stage, addons::{InstallInputs, execute}, error::{Error, Result}, }; @@ -16,12 +16,10 @@ impl VirgoManager { &self, id: Uuid, base: &VirgoLayer, - addons: &Addons, - cx: &Context, progress: &watch::Sender>, cancellation: &CancellationToken, ) -> Result { - let destination = self.root.join("addons").join(id.to_string()); + let destination = self.root().join("addons").join(id.to_string()); if let Some(artifact) = cache::load(&destination, Some(id)).await? { return Ok(artifact); } @@ -32,33 +30,37 @@ impl VirgoManager { if let Some(artifact) = cache::load(&destination, Some(id)).await? { return Ok(artifact); } - let component = addons.component(id); - let dependency = addons.dependency(id); + let component = self.addons.component(id); + let dependency = self.addons.dependency(id); let (payload, resources) = if let Some(release) = &component { - let payload = release.path(cx.directories()); + let payload = release.path(self.cx.directories()); release.validate(&payload).await?; (payload, release.resources()) } else if let Some(release) = &dependency { - let payload = release.path(cx.directories()); + let payload = release.path(self.cx.directories()); release.validate(&payload).await?; (payload, release.resources()) } else { return Err(AddonError::NotFound(id).into()); }; - let soda = addons + let soda = self + .addons .component(base.id) .ok_or(AddonError::NotFound(base.id))?; - let runner = soda.addon().load_runner(cx.directories(), None).await?; - let winebridge = addons + let runner = soda + .addon() + .load_runner(self.cx.directories(), None) + .await?; + let winebridge = self + .addons .latest_component(Slot::WineBridge) .ok_or(EnvironmentError::ComponentNotInstalled(Slot::WineBridge))? - .path(cx.directories()); + .path(self.cx.directories()); self.build( id, &destination, runner.as_ref(), base, - cx, cancellation, |prefix, runner| async move { let mut env_vars = EnvVars::default(); diff --git a/src/environment/prefix/virgo/mod.rs b/src/environment/prefix/virgo/mod.rs index 17a9996..1f5d7c6 100644 --- a/src/environment/prefix/virgo/mod.rs +++ b/src/environment/prefix/virgo/mod.rs @@ -22,13 +22,12 @@ pub(super) async fn prepare( runner: &dyn Runner, root: &Path, cx: &Context, - addons: &crate::Addons, + virgo: &VirgoManager, progress: &watch::Sender>, cancellation: &CancellationToken, ) -> Result<()> { - let artifacts::VirgoComposition { base, overlays } = cx - .virgo() - .resolve(config, runner, cx, addons, progress, cancellation) + let artifacts::VirgoComposition { base, overlays } = virgo + .resolve(config, runner, progress, cancellation) .await?; if cancellation.is_cancelled() { return Err(Error::Cancelled); @@ -62,33 +61,13 @@ pub(super) async fn prepare( /// Mount resolved layers after the environment workflow has stopped Wine. async fn mount(root: &Path, layers: Vec, cx: &Context) -> Result<()> { - if !existing_mount(root, &layers, cx).await? { - let prefix = root.join("prefix"); - ensure_empty_dir(&prefix).await?; - cx.fvs() - .await? - .mount(&prefix, layers, Some(root.join("upper"))) - .await?; - } - Ok(()) -} - -async fn existing_mount(root: &Path, layers: &[Layer], context: &Context) -> Result { let prefix = root.join("prefix"); - let mounts = context.fvs().await?.list_mounts().await?; - let Some(spec) = mounts - .into_iter() - .filter_map(|mount| mount.spec) - .find(|spec| spec.mount_point == prefix.to_string_lossy()) - else { - return Ok(false); - }; - if spec.layers != layers - || spec.upper_path.as_deref() != Some(root.join("upper").to_string_lossy().as_ref()) - { - return Err(VirgoError::MountMismatch(prefix).into()); - } - Ok(true) + ensure_empty_dir(&prefix).await?; + cx.fvs() + .await? + .mount(&prefix, layers, Some(root.join("upper"))) + .await?; + Ok(()) } pub(super) async fn release(root: &Path, context: &Context) -> Result<()> { @@ -133,10 +112,6 @@ pub enum VirgoError { /// Virgo cannot mount a prefix over a nonempty mountpoint. #[error("mountpoint is not empty: {0}")] DirtyMountpoint(std::path::PathBuf), - #[error( - "mounted layers or writable upper differ from the selected composition at {0}; call stop() and retry" - )] - MountMismatch(std::path::PathBuf), /// A published artifact has an unsupported format or incomplete installed effects. #[error("invalid Virgo artifact: {0}")] InvalidArtifact(std::path::PathBuf), diff --git a/src/utils/context.rs b/src/utils/context.rs index 4b5c898..2e67fdd 100644 --- a/src/utils/context.rs +++ b/src/utils/context.rs @@ -3,11 +3,7 @@ use download_manager::manager::{DownloadManager, DownloadManagerConfig}; use http_client::HttpClient; use std::{path::PathBuf, sync::Arc}; #[cfg(feature = "fvs")] -use { - crate::{environment::VirgoManager, utils::absolute_path}, - fvs_rs::Fvs2dClient, - tokio::sync::OnceCell, -}; +use {crate::utils::absolute_path, fvs_rs::Fvs2dClient, tokio::sync::OnceCell}; struct ContextInner { directories: Directories, @@ -17,8 +13,6 @@ struct ContextInner { fvs2d_executable: PathBuf, #[cfg(feature = "fvs")] fvs: OnceCell, - #[cfg(feature = "fvs")] - virgo: VirgoManager, } #[derive(Clone)] @@ -37,8 +31,6 @@ impl Context { DownloadManagerConfig::default(), )?); Ok(Self(Arc::new(ContextInner { - #[cfg(feature = "fvs")] - virgo: VirgoManager::new(directories.data_dir().join("virgo")), directories, http_client, downloader, @@ -75,11 +67,6 @@ impl Context { &self.0.http_client } - #[cfg(feature = "fvs")] - pub(crate) fn virgo(&self) -> &VirgoManager { - &self.0.virgo - } - #[cfg(feature = "fvs")] pub(crate) async fn fvs(&self) -> Result<&Fvs2dClient> { self.0 From 21fb0fcfd59cfed442e23842c4c33c89c61a8094 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Sat, 12 Sep 2026 01:31:43 +0530 Subject: [PATCH 21/24] docs: trim README to overview and usage --- README.md | 203 ++++-------------------------------------------------- 1 file changed, 15 insertions(+), 188 deletions(-) diff --git a/README.md b/README.md index 50b03f2..1563a13 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ bottles-core = { version = "0.1", default-features = false } Without `fvs`, snapshot APIs and Virgo storage are not compiled. Standard addon changes always use direct writes; failed or cancelled recipes can leave partial -prefix changes. Explicit Standard snapshots initialize FVS history on demand. +prefix changes. [Source] | [Issue tracker] @@ -36,193 +36,20 @@ The crate is centered around six types: cancellation. Execution settings live in `BottleState::environment()` as an `EnvironmentConfig`. -Cloned bottle handles share an operation mutex. Each runtime call attaches through -a temporary environment connection; registered programs use the bottle's settings. `Bottle::launch(ProgramSpec)` runs an -unregistered executable; `Bottle::launch_program(uuid)` runs a registration. -Both return `Operation` with the initial Windows process ID. - -DLL override queries and changes also return `Operation`, exposing preparation -progress and cooperative cancellation. Existing `.await` calls continue to work. -An `Environment` represents a running execution environment and holds a required -WineBridge connection. `attach_or_start` constructs it directly from the owner's -configuration and location, including after an application restart. The handle retains -neither configuration nor services; dropping it leaves Wine running. Launch and DLL -operations hold the owner lock and forward progress and cancellation. Registered -launch resolves its program before startup; other calls use `with_environment`. - -Initialization, configuration edits, and shutdown are associated functions on -`Environment` that take the owner's inputs and return completion, without requiring -a running handle. -The owner retains configuration, persistence, publication, and coordination. -`PrefixBackend` owns how a runnable prefix is created and maintained: initialization, supported edits, software -materialization, composition, and storage release. Standard and Virgo implementations -live below this boundary; environment workflows do not distinguish between them. -Backends stop their initialization and installer processes through shared runtime -helpers, without invoking owner lifecycle operations. - -Process inspection and group kill attach to an existing runtime without starting -Wine or inspecting FVS mounts. Successful attachment bypasses preparation. Otherwise, -startup stops Wine and releases existing storage before preparing and mounting the -selected composition. Dropping handles leaves Wine running. Explicit `stop()` waits -for wineserver before unmounting, even when WineBridge cannot be reached. -Unreachable discovery requires `stop()` before retrying. - -Initialization and recipes run without game wrappers. Cancellation finishes cleanup -before returning; failed shutdown retains storage. Temporary cache failures can -require manual cleanup of the reported prefix. Automatic recovery and concurrent -independent clients are deferred. - -`Bottle::edit(|state| { /* changes */ Ok(()) })` returns an `Operation<()>`. -The callback receives a draft of the latest state under the owner lock. Edit -`name`, `programs`, and `environment` directly; errors discard the whole draft. -Metadata edits work while running. Call `stop()` before changing environment -settings, including through `set_component`, `remove_component`, or `install`. -The prefix backend is fixed at creation. Standard dependencies may be appended; Virgo -selections may also be removed or reordered. Standard changes execute installers -before saving and keep direct-write semantics. Virgo creation and edits save -selections without building layers or changing private prefix data. Preparation -errors surface when starting the environment. - -Local releases live at `components/releases//` or -`dependencies/releases//`. Both `release.toml` and `payload/` are required; -they are published and removed together. `Release` is persisted directly and owns the local resource paths and frozen recipes. -The manager keeps separate typed component and dependency maps, with UUID uniqueness -checked across both families. -Each ordered resource keeps a path relative to `payload/` and its recipe. -Components use the payload directory itself; dependency resources use local filenames. -Download URLs and checksums remain in the catalog and are used only during fetching. -`Addon` preserves identity, requirements, and frozen runtime variables in owner -state. A local release exists only as a complete record and -payload. Loading rejects incomplete releases. Removal renames the entire release -directory out of its published location, removes it from the typed map, then deletes -the withdrawn directory. A deletion failure leaves only unpublished staging data. -Existing environments keep their runtime variables after removal. Source installation -and runtime executables still require their payloads. -Fetching a removed release resolves it from the current catalog; imported components -must be imported again with a new UUID. Built Virgo caches are not removed. - -Omitted component `steps` use the bundled recipe for that slot. -Explicit `steps` replace the default entirely; `steps: []` means no installation steps. -The resolved recipe is frozen into the local release, so template updates do not -change existing releases. Dependency steps come from the catalog (omitted means empty), -and both families take their requirements from the catalog. -Changed contents or recipes require a new UUID. Catalog refresh -only replaces catalog snapshots; it neither changes releases nor discovers files. - -`import_component(path, slot, name, version)` extracts a local `.tar`, `.tar.gz`/`.tgz`, -or `.tar.xz`/`.txz` archive containing one top-level component directory. It assigns a -fresh UUID and freezes a bundled recipe into the release. Folder imports are not -supported. Imports work offline and leave the source archive untouched. Executable -permissions and internal relative symlinks are preserved; escaping links are rejected. -Catalog component downloads and local imports share archive preparation and release -publication; catalog downloads additionally transfer and verify the archive. Templates are never read during installation -or startup. Publication and loading check recognized runner layouts, WineBridge/UMU -entrypoints, and recipe Copy sources before making a component selectable. -Old indexes, slot/version directories, and the former top-level `releases/` directory -are ignored and left untouched; -re-download or explicitly import a component archive. There is no automatic migration. - -Virgo builds a clean shared base from the latest catalog runner named `Soda` -(case-insensitive, semantic-version ordering). That exact release must already be -downloaded. The base manifest pins its release and immutable FVS revision across -catalog refreshes. The base is initialized once and reused; there is no rebuild -operation. Stopped preparation builds missing artifacts and resolves the selected -composition before execution. The complete base lives under `virgo/soda`, with -its manifest pinning the Soda UUID and exact commit. - -The base, addons, and runner adapters are each published as one immutable directory: - -```text -virgo/addons// # adapters: virgo/adapters//; base: virgo/soda/ - manifest.toml - filesystem/ - registry/ - user.reg - system.reg -``` - -The manifest records `_version`, the release `id`, and its exact FVS `commit` ID. -Repository and registry paths are derived from the artifact directory. Manifests -use `next_config` for versioned loading and saving; no migrations are defined. -The base's registry files contain initial hives; adapter and addon registry files -contain patches against those hives. The filesystem, both registry files, and -manifest are built in staging and published with one rename. Scratch mounts and temporary baselines stay outside -the published directory. Wine is stopped before diffing or unmounting; failed -shutdown or unmount retains staging for explicit cleanup. - -Preparation loads the complete artifact before looking up a source release or -waiting for the shared build lock. A cache miss acquires the lock and checks again -before building. Reads never wait for unrelated artifact construction. -A cache hit needs no addon source record or payload. Missing manifests, -repositories, or patches are errors; no registry changes are represented by -valid empty patches. Resolved artifacts supply layers and registry locations -directly to composition, without looking up repository history or resolving UUIDs -again. Existing artifacts are never rebuilt or replaced automatically. -Legacy `virgo/layers`, `virgo/registry`, and `virgo/base.toml` are not used. -The base layout is a clean format break: an old `virgo/soda` directory containing -`prefix/` and adapters is rejected, not migrated or overwritten. Recreating a base -requires explicitly clearing its dependent addon and adapter caches as well; -they were built against that pinned base. Existing data is left untouched. -There are no generation directories or upgrade-triggered rebuilds. - -Every addon recipe must install against pinned Soda alone. Cache construction -uses no layers, registry patches, or environment contributions from other addons, -and no owner settings, wrappers, or private writable data. Requirements are validated against -the final environment selections. UUID remains the sole cache -identity: completed caches survive runner and settings changes. Runner -adapters are built using the selected runner over the pinned base. Adapter and -addon builds record registry changes as patches and exclude full hives from -their committed filesystem layers. Shared construction is serialized within one -core instance. `Bottles::open` constructs one shared `VirgoManager` and passes it to -the bottle manager and bottle handles. It retains `Context` and `Addons`, deriving -artifact paths from that context and resolving sources through that addon manager. -It owns staging and the build mutex, and retains no owner configuration. -The context does not retain the manager, so its addon handle creates no ownership cycle. -Constructing it does not access storage or start FVS. The context separately owns -the lazy FVS connection used by both Virgo and Standard snapshots. Adapter and addon -builds share the same shutdown, diff, unmount, and publication workflow. Standard -installers continue using their owner's runner and preserve displaced files as -backups. Virgo disables those backups because the lower layer retains the originals. - -Virgo composition is always Soda base → selected runner adapter → components in -slot order → dependencies in persisted order → private writable upper. The manager -resolves a temporary `VirgoComposition` containing the base and ordered overlays -without accessing owner data. Owner preparation consumes that same ordering for -registry patches and filesystem layers, after resolution has completed. There is -no second persisted list of selections or layers. The shared base remains pinned -and completed UUID caches remain immutable. - -Each preparation copies the base's published initial hives, then applies adapter -patches, addon patches in selection order, and private changes -relative to the previous baseline. The owner is checkpointed before that -mutation; failure restores its prior data while keeping the selected configuration -saved for retry. Shared artifact builds happen before the checkpoint. Private -files, updates, saves, and whiteouts keep normal overlay precedence; the upper is -never pruned. A later WineBridge startup failure does not undo successful registry -preparation. - -Runtime variables are derived once from resolved recipes during acquisition and -saved in `Addon`. Both backends merge these saved values in component slot order, -then dependency order, without resolving shared releases. Virgo caches contain -filesystem and registry effects. Installation still executes ordered environment -steps so commands see the variables declared so far. Explicit owner settings take -precedence; execution-owned variables such as -`WINEPREFIX`, `WINEARCH`, and `PROTONPATH` are applied last. Snapshots capture owner -metadata, addon selections, registry baseline, and private prefix data while -stopped, without WineBridge discovery files. - -Standard component removal still uses the shared release’s recipe. Removing that -release preserves runtime variables but makes recipe-based uninstallation unavailable. - -Bottle configuration uses version 1. `EnvironmentConfig::backend` selects -`PrefixBackend::Standard` or `PrefixBackend::Virgo` and is serialized under the existing -`environment.storage` key. Layers are derived from selected addons rather than stored -in owner state. -Completed addon caches remain reusable. Snapshots stop the runtime -and capture existing configuration, registry baseline, and private data without -preparing Virgo. Pending selections remain pending after restoration and are -prepared at the next launch. Explicit snapshots create a new commit even when -contents are unchanged; `bottles-next:auto-checkpoint` is a reserved message. +Use `Bottle::edit` to update bottle state, and call `stop()` before changing +its environment settings. `Bottle::launch(ProgramSpec)` runs an unregistered +program; `Bottle::launch_program(uuid)` runs a saved registration. Dropping a +bottle handle leaves Wine running; call `stop()` to shut it down. + +Choose a prefix backend when creating a bottle. Standard installs directly into +a conventional Wine prefix. Virgo is experimental: it combines shared immutable +layers with each bottle's private writable data, preparing missing layers at +startup. Virgo requires FVS and a downloaded Soda runner to build its base. + +Fetch addons from the component and dependency catalogs, or use +`Addons::import_component` to import a component archive. Local releases are +identified by UUID. Removing a release deletes its installation inputs; +runtime executables and Standard recipe-based removal still require those files. Operations are lazy. Await them, call `cancel().await`, or spawn them and explicitly detach the task; dropping an operation abandons it. From 86e4136ad57d6007c4d0b5aecde4102b7576ea99 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Sat, 12 Sep 2026 02:13:09 +0530 Subject: [PATCH 22/24] refactor(addons): separate runtime declarations from installer environment --- src/addons/addon.rs | 2 +- src/addons/installer/engine.rs | 119 ++++-------------- src/addons/installer/mod.rs | 40 +++--- src/addons/release.rs | 17 ++- src/environment/config.rs | 5 +- src/environment/mod.rs | 4 +- src/environment/prefix/standard.rs | 5 - .../prefix/virgo/artifacts/software.rs | 5 +- 8 files changed, 65 insertions(+), 132 deletions(-) diff --git a/src/addons/addon.rs b/src/addons/addon.rs index 08fb297..1f8c1e7 100644 --- a/src/addons/addon.rs +++ b/src/addons/addon.rs @@ -74,7 +74,7 @@ impl Addon { /// Returns this addon's frozen runtime environment variables. /// - /// Values are derived from its resolved installation recipes during acquisition + /// Values are collected from the selected recipe's declarations during acquisition /// and saved with the selection, so they remain available after the shared /// release is removed. These are this addon's contributions only; environment /// configuration combines them with other addons and applies owner overrides last. diff --git a/src/addons/installer/engine.rs b/src/addons/installer/engine.rs index ac41fac..265ab92 100644 --- a/src/addons/installer/engine.rs +++ b/src/addons/installer/engine.rs @@ -12,7 +12,7 @@ use crate::{ addons::InstallerError, error::{Error, Result, ResultExt}, runner::{Command, Runner, Spawnable}, - utils::{archive, env_vars::EnvVars, exists}, + utils::{archive, exists}, winebridge::WineBridgeClient, }; @@ -33,32 +33,12 @@ pub(crate) async fn execute( cancellation: &CancellationToken, on_step: impl Fn(&InstallStep) + Send, ) -> Result<()> { - let InstallInputs { - prefix, - runner, - winebridge, - env_vars, - explicit_env_vars, - } = inputs; check_cancellation(cancellation)?; for resource in resources { let source = payload_root.join(&resource.path); for step in &resource.steps { on_step(step); - execute_step( - InstallInputs { - prefix, - runner, - winebridge, - env_vars: &mut *env_vars, - explicit_env_vars, - }, - &source, - step, - backup_files, - cancellation, - ) - .await?; + execute_step(inputs, &source, step, backup_files, cancellation).await?; check_cancellation(cancellation)?; } } @@ -67,8 +47,8 @@ pub(crate) async fn execute( /// Attempts to undo a recipe in reverse resource and step order. /// -/// File copies are restored or removed. Environment entries are -/// removed and DLL overrides are deleted. Other step kinds have no inverse and are skipped with a +/// File copies are restored or removed and DLL overrides are deleted. Runtime variable +/// declarations are ignored. Other step kinds have no inverse and are skipped with a /// warning. File, bridge and override failures are logged and ignored; cancellation is returned. /// The enclosing prefix scope owns Wine shutdown. pub(crate) async fn uninstall<'a>( @@ -78,60 +58,21 @@ pub(crate) async fn uninstall<'a>( cancellation: &CancellationToken, on_step: impl Fn(&InstallStep) + Send, ) -> Result<()> { - let InstallInputs { - prefix, - runner, - winebridge, - env_vars, - explicit_env_vars, - } = inputs; - check_cancellation(cancellation)?; for step in steps.rev() { on_step(step); - uninstall_step( - InstallInputs { - prefix, - runner, - winebridge, - env_vars: &mut *env_vars, - explicit_env_vars, - }, - step, - item_id, - cancellation, - ) - .await?; + uninstall_step(inputs, step, item_id, cancellation).await?; check_cancellation(cancellation)?; } Ok(()) } -/// Collects recipe variables in declaration order; later values override earlier ones. -pub(crate) fn replay_env_vars<'a>( - env_vars: &mut EnvVars, - steps: impl IntoIterator, -) { - for step in steps { - if let InstallStep::SetEnvironment { name, value } = step { - env_vars.insert(name.clone(), value.clone()); - } - } -} - async fn maintenance_bridge( runner: &dyn Runner, prefix: &Path, executable: &Path, - env_vars: &EnvVars, - explicit_env_vars: &EnvVars, ) -> Result { - let command = WineBridgeClient::command( - runner, - prefix, - executable, - env_vars.iter().chain(explicit_env_vars.iter()), - ); + let command = WineBridgeClient::command(runner, prefix, executable, std::iter::empty()); WineBridgeClient::connect_or_spawn(prefix, command).await } @@ -146,10 +87,9 @@ async fn execute_step( prefix, runner, winebridge, - env_vars, - explicit_env_vars, } = inputs; match step { + InstallStep::SetEnvironment { .. } => {} InstallStep::Copy { source, destination, @@ -164,12 +104,15 @@ async fn execute_step( InstallStep::Extract { destination } => { extract_into(resource, prefix, destination, backup_files, cancellation).await?; } - InstallStep::Execute { arguments } => { + InstallStep::Execute { + arguments, + env_vars, + } => { let mut command = Command::new(resource); for argument in arguments { command = command.arg(argument); } - for (name, value) in env_vars.iter().chain(explicit_env_vars.iter()) { + for (name, value) in env_vars.iter() { command = command.env(name, value); } let status = @@ -178,11 +121,11 @@ async fn execute_step( return Err(InstallerError::InstallerFailed(status).into()); } } - InstallStep::RegisterDlls { dlls } => { + InstallStep::RegisterDlls { dlls, env_vars } => { for dll in dlls { check_cancellation(cancellation)?; let mut command = Command::new("regsvr32").arg("/s").arg(prefix.join(dll)); - for (name, value) in env_vars.iter().chain(explicit_env_vars.iter()) { + for (name, value) in env_vars.iter() { command = command.env(name, value); } let status = @@ -198,25 +141,19 @@ async fn execute_step( name, value, } => { - let bridge = - maintenance_bridge(runner, prefix, winebridge, env_vars, explicit_env_vars).await?; + let bridge = maintenance_bridge(runner, prefix, winebridge).await?; check_cancellation(cancellation)?; bridge .set_registry_value(*hive, key.clone(), name.clone(), value.clone()) .await?; } InstallStep::SetDllOverrides { dlls, mode } => { - let bridge = - maintenance_bridge(runner, prefix, winebridge, env_vars, explicit_env_vars).await?; + let bridge = maintenance_bridge(runner, prefix, winebridge).await?; for dll in dlls { check_cancellation(cancellation)?; bridge.set_dll_override(dll.clone(), *mode).await?; } } - InstallStep::SetEnvironment { name, value } => { - env_vars.insert(name.clone(), value.clone()); - WineBridgeClient::shutdown_existing(prefix).await?; - } } Ok(()) } @@ -231,30 +168,22 @@ async fn uninstall_step( prefix, runner, winebridge, - env_vars, - explicit_env_vars, } = inputs; match step { + InstallStep::SetEnvironment { .. } => {} InstallStep::Copy { destination, .. } => { if let Err(error) = uninstall_file(prefix, destination).await { tracing::warn!(%error); } } - InstallStep::SetEnvironment { name, .. } => { - env_vars.remove(name); - WineBridgeClient::shutdown_existing(prefix).await.log_warn(); - } InstallStep::SetDllOverrides { dlls, .. } => { - let bridge = - match maintenance_bridge(runner, prefix, winebridge, env_vars, explicit_env_vars) - .await - { - Ok(bridge) => bridge, - Err(error) => { - tracing::warn!(%error); - return Ok(()); - } - }; + let bridge = match maintenance_bridge(runner, prefix, winebridge).await { + Ok(bridge) => bridge, + Err(error) => { + tracing::warn!(%error); + return Ok(()); + } + }; for dll in dlls.iter().rev() { check_cancellation(cancellation)?; match bridge.delete_dll_override(dll.clone()).await { diff --git a/src/addons/installer/mod.rs b/src/addons/installer/mod.rs index d0723aa..0ba8063 100644 --- a/src/addons/installer/mod.rs +++ b/src/addons/installer/mod.rs @@ -6,16 +6,18 @@ //! # Installation //! //! Resources and steps are applied in declaration order. Steps may copy or -//! extract files, run installers, register DLLs, update the registry, configure -//! DLL overrides, or change the bottle environment. Changes made by completed -//! steps remain if a later step fails; the bottle storage layer is responsible +//! extract files, run installers, register DLLs, update the registry, or configure +//! DLL overrides. `SetEnvironment` declarations are collected into release metadata +//! during acquisition and ignored during installation and uninstall. Installer +//! commands declare their own variables. Changes made by completed steps remain +//! if a later step fails; the bottle storage layer is responsible //! for any transaction-level rollback. //! //! # Component removal //! //! Resources and steps are visited in reverse order. Uninstallation can restore -//! copied files, delete DLL overrides, and remove environment entries. Actions -//! without an inverse—executing programs, extracting archives, registering DLLs, +//! copied files and delete DLL overrides. Actions without an inverse—executing +//! programs, extracting archives, registering DLLs, //! and setting registry values—are skipped. Consequently, a recipe is not //! necessarily fully reversible. Dependencies cannot be removed separately from //! their bottle. @@ -48,7 +50,7 @@ use crate::{ use super::deserialize_non_empty_string; -pub(crate) use engine::{execute, replay_env_vars, uninstall}; +pub(crate) use engine::{execute, uninstall}; pub(crate) use recipes::steps as recipe_steps; /// A local installation resource and its frozen recipe. @@ -69,7 +71,7 @@ impl InstallResource { } } -/// A declarative operation applied while installing an addon resource. +/// An installation action or runtime environment declaration for an addon resource. /// /// Steps are serialized as part of Bottles' internal catalog schema; their wire /// representation is not a stable interchange API. The module overview describes @@ -90,11 +92,13 @@ pub(crate) enum InstallStep { }, /// Runs the resource through the configured runner and requires a successful exit status. /// - /// The process receives the bottle environment as it exists at this step. + /// Variables apply only to this command, in addition to the host environment. Execute { /// Passed directly to the child process without shell parsing. #[serde(default)] arguments: Vec, + #[serde(default, skip_serializing_if = "EnvVars::is_empty")] + env_vars: EnvVars, }, /// Extracts a supported tar archive and copies its regular files into the Wine prefix. /// @@ -108,15 +112,16 @@ pub(crate) enum InstallStep { }, /// Registers DLLs silently with `regsvr32` in list order. /// - /// Each process receives the bottle environment as it exists at this step. + /// Variables apply only to these commands, in addition to the host environment. RegisterDlls { /// DLL paths intended to be relative to the Wine prefix. dlls: Vec, + #[serde(default, skip_serializing_if = "EnvVars::is_empty")] + env_vars: EnvVars, }, /// Sets a registry value through WineBridge. /// - /// WineBridge is started with the current bottle environment when it is not - /// already running. + /// WineBridge is started with the runner's maintenance environment when needed. SetRegistryValue { hive: RegistryHive, /// Non-empty registry key path. @@ -128,7 +133,7 @@ pub(crate) enum InstallStep { }, /// Applies the same Wine DLL override mode to each named DLL. /// - /// WineBridge is started with the current bottle environment when needed. + /// WineBridge is started with the runner's maintenance environment when needed. /// Uninstall deletes these overrides rather than restoring their previous modes. SetDllOverrides { /// DLL names whose overrides are changed, in application order. @@ -136,14 +141,15 @@ pub(crate) enum InstallStep { /// Applied uniformly; mixed per-DLL modes require separate steps. mode: DllOverrideMode, }, - /// Overwrites an entry in the bottle's process environment. + /// Declares a launch variable, collected into addon metadata during acquisition. /// - /// The previous value is not retained. Uninstall removes the name rather than restoring a - /// previous value, and WineBridge is stopped so a later operation starts it with the change. + /// Later declarations win. Installation and uninstall ignore this declaration; + /// installer commands use their own explicit variables. SetEnvironment { name: String, value: String }, } /// Execution inputs for a recipe in an owner prefix or shared build. +#[derive(Clone, Copy)] pub(crate) struct InstallInputs<'a> { /// The prepared Wine prefix receiving recipe changes. pub(crate) prefix: &'a Path, @@ -151,8 +157,4 @@ pub(crate) struct InstallInputs<'a> { pub(crate) runner: &'a dyn Runner, /// The WineBridge executable selected by the execution workflow. pub(crate) winebridge: &'a Path, - /// The environment updated by `SetEnvironment` steps and passed to processes. - pub(crate) env_vars: &'a mut EnvVars, - /// Explicit owner settings override recipe contributions for every process. - pub(crate) explicit_env_vars: &'a EnvVars, } diff --git a/src/addons/release.rs b/src/addons/release.rs index 4948b80..7f9a954 100644 --- a/src/addons/release.rs +++ b/src/addons/release.rs @@ -109,8 +109,7 @@ impl Release { requirements: Vec, resource: InstallResource, ) -> Self { - let mut env_vars = EnvVars::default(); - super::installer::replay_env_vars(&mut env_vars, &resource.steps); + let env_vars = collect_env_vars(&resource.steps); Self { addon: Addon::new( id, @@ -154,8 +153,7 @@ impl Release { requirements: Vec, resources: Vec, ) -> Self { - let mut env_vars = EnvVars::default(); - super::installer::replay_env_vars(&mut env_vars, resources.iter().flat_map(|r| &r.steps)); + let env_vars = collect_env_vars(resources.iter().flat_map(|resource| &resource.steps)); Self { addon: Addon::new( id, @@ -173,3 +171,14 @@ impl Release { impl next_config::Config for Release { const VERSION: u32 = 1; } + +/// Extracts launch declarations once, in resource and step order; later values win. +fn collect_env_vars<'a>(steps: impl IntoIterator) -> EnvVars { + let mut env_vars = EnvVars::default(); + for step in steps { + if let InstallStep::SetEnvironment { name, value } = step { + env_vars.insert(name.clone(), value.clone()); + } + } + env_vars +} diff --git a/src/environment/config.rs b/src/environment/config.rs index 3b05df3..d088361 100644 --- a/src/environment/config.rs +++ b/src/environment/config.rs @@ -178,8 +178,8 @@ impl EnvironmentConfig { .filter_map(|slot| self.component(slot)) } - /// Combines saved addon contributions; later selections override earlier ones. - pub(crate) fn addon_env_vars(&self) -> EnvVars { + /// Combines saved addon contributions in selection order, then applies bottle overrides. + pub(crate) fn effective_env_vars(&self) -> EnvVars { let mut vars = EnvVars::default(); for addon in self.ordered_components() { vars.extend(addon.env_vars().clone()); @@ -187,6 +187,7 @@ impl EnvironmentConfig { for addon in &self.dependencies { vars.extend(addon.env_vars().clone()); } + vars.extend(self.env_vars.clone()); vars } diff --git a/src/environment/mod.rs b/src/environment/mod.rs index fc43e2b..6537a8e 100644 --- a/src/environment/mod.rs +++ b/src/environment/mod.rs @@ -61,7 +61,7 @@ impl Environment { if let Some(environment) = Self::try_attach(root).await? { return Ok(environment); } - let env_vars = config.addon_env_vars(); + let env_vars = config.effective_env_vars(); if cancellation.is_cancelled() { return Err(Error::Cancelled); } @@ -91,7 +91,7 @@ impl Environment { runner.as_ref(), &prefix, config.winebridge().path(cx.directories()), - env_vars.iter().chain(config.env_vars.iter()), + env_vars.iter(), )); match WineBridgeClient::connect_or_spawn(&prefix, command).await { Ok(bridge) => Ok(Self { bridge }), diff --git a/src/environment/prefix/standard.rs b/src/environment/prefix/standard.rs index 15cd262..14aec4f 100644 --- a/src/environment/prefix/standard.rs +++ b/src/environment/prefix/standard.rs @@ -84,15 +84,12 @@ pub(super) async fn apply( .await?; let prefix = root.join("prefix"); let winebridge = candidate.winebridge().path(cx.directories()); - let mut env_vars = previous.addon_env_vars(); for release in removals { let result = uninstall( InstallInputs { prefix: &prefix, runner: runner.as_ref(), winebridge: &winebridge, - env_vars: &mut env_vars, - explicit_env_vars: &candidate.env_vars, }, release.recipe(), release.id(), @@ -119,8 +116,6 @@ pub(super) async fn apply( prefix: &prefix, runner: runner.as_ref(), winebridge: &winebridge, - env_vars: &mut env_vars, - explicit_env_vars: &candidate.env_vars, }, &payload, resources, diff --git a/src/environment/prefix/virgo/artifacts/software.rs b/src/environment/prefix/virgo/artifacts/software.rs index 3e35d05..e3493c0 100644 --- a/src/environment/prefix/virgo/artifacts/software.rs +++ b/src/environment/prefix/virgo/artifacts/software.rs @@ -6,7 +6,7 @@ use uuid::Uuid; use super::{VirgoLayer, VirgoManager, cache}; use crate::{ - AddonError, EnvVars, EnvironmentError, Progress, Slot, Stage, + AddonError, EnvironmentError, Progress, Slot, Stage, addons::{InstallInputs, execute}, error::{Error, Result}, }; @@ -63,14 +63,11 @@ impl VirgoManager { base, cancellation, |prefix, runner| async move { - let mut env_vars = EnvVars::default(); execute( InstallInputs { prefix: &prefix, runner, winebridge: &winebridge, - env_vars: &mut env_vars, - explicit_env_vars: &EnvVars::default(), }, &payload, resources, From c5c0b5b501225b2f55b4f95be4f45a39196fa0b6 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Sat, 12 Sep 2026 02:28:54 +0530 Subject: [PATCH 23/24] fix(core): keep bottle opening registry-only --- src/bottle/manager.rs | 38 +++++++------------------------------- 1 file changed, 7 insertions(+), 31 deletions(-) diff --git a/src/bottle/manager.rs b/src/bottle/manager.rs index 0eb49ab..e847466 100644 --- a/src/bottle/manager.rs +++ b/src/bottle/manager.rs @@ -268,41 +268,17 @@ impl BottleManager { /// Opens the bottle identified by `id`. /// /// Repeated calls through this manager or its clones return handles to the - /// same live state. Once a UUID is in the registry, this method does not - /// reload `bottle.toml` or observe external changes. If a persisted bottle - /// is not yet interned, opening it adds the handle to the registry and - /// notifies manager watchers. + /// same live state. Only bottles loaded at startup or created through this + /// manager are opened; this method does not search storage or observe + /// external changes. /// /// # Errors /// - /// Returns [`BottleError::NotFound`] if `bottle.toml` is absent, is not a - /// regular file, or its metadata cannot be inspected. Returns - /// [`BottleError::IdMismatch`] if the loaded UUID differs from `id`. - /// Configuration loading failures are also returned. + /// Returns [`BottleError::NotFound`] if `id` is not in the registry. pub async fn open(&self, id: Uuid) -> Result { - if let Some(bottle) = self.registry.get(id) { - return Ok(bottle); - } - let path = self.context.directories().bottle(id).join("bottle.toml"); - if !fs::metadata(&path).await.is_ok_and(|entry| entry.is_file()) { - return Err(BottleError::NotFound(id).into()); - } - let state: BottleState = next_config::load(path).await?; - if state.id != id { - return Err(BottleError::IdMismatch { - expected: id, - actual: state.id, - } - .into()); - } - let bottle = Bottle::from_state( - state, - self.context.clone(), - self.addons.clone(), - #[cfg(feature = "fvs")] - self.virgo.clone(), - )?; - Ok(self.registry.intern(bottle)) + self.registry + .get(id) + .ok_or_else(|| BottleError::NotFound(id).into()) } /// Returns the bottles currently known to this manager. From 1b69ae1bf27f13a335d13473e14f1d5de668f103 Mon Sep 17 00:00:00 2001 From: Inam Ul Haq Date: Sat, 12 Sep 2026 02:46:55 +0530 Subject: [PATCH 24/24] refactor(history): delegate FVS stream completion --- src/environment/history.rs | 148 +++++-------------------------------- 1 file changed, 18 insertions(+), 130 deletions(-) diff --git a/src/environment/history.rs b/src/environment/history.rs index fdfb046..8a9e012 100644 --- a/src/environment/history.rs +++ b/src/environment/history.rs @@ -3,11 +3,7 @@ use super::prefix::FVS_BLOCK_SIZE; use crate::{Context, Progress, Stage, Transfer, error::Result}; -use futures_core::Stream; -use futures_util::TryStreamExt; -use fvs_rs::{ - Commit, Progress as FvsProgress, Repository, RestoreResponse, error::Error as FvsError, -}; +use fvs_rs::{Commit, Progress as FvsProgress, Repository, RestoreResponse}; use std::path::Path; use tokio::sync::watch; @@ -50,13 +46,11 @@ pub(crate) async fn capture( if !crate::utils::exists(&root.join(".fvs2")).await? { client.new_repository(root, FVS_BLOCK_SIZE).await?; } - let stream = client - .commit_stream(&repository(root), message, allow_empty) - .await?; - finish_commit(stream, |event| { - progress.send_replace(Some(Progress::transferring(stage.clone(), event.into()))); - }) - .await + Ok(client + .commit_with_progress(&repository(root), message, allow_empty, |event| { + progress.send_replace(Some(Progress::transferring(stage.clone(), event.into()))); + }) + .await?) } pub(crate) async fn restore( @@ -65,15 +59,20 @@ pub(crate) async fn restore( cx: &Context, progress: &watch::Sender>, ) -> Result { - let stream = cx + Ok(cx .fvs() .await? - .restore_stream(&repository(root), revision, None::<&Path>, true, false) - .await?; - finish_restore(stream, |event| { - progress.send_replace(Some(Progress::transferring(Stage::Restoring, event.into()))); - }) - .await + .restore_with_progress( + &repository(root), + revision, + None::<&Path>, + true, + false, + |event| { + progress.send_replace(Some(Progress::transferring(Stage::Restoring, event.into()))); + }, + ) + .await?) } /// Restore both data and configuration on a rejected mutation. Nothing is published @@ -97,114 +96,3 @@ pub(crate) async fn recover( } result } - -/// Drains an FVS commit stream, forwarding every frame and requiring a terminal commit. -async fn finish_commit( - stream: impl Stream>, - on_progress: impl FnMut(&FvsProgress), -) -> Result { - finish_stream( - stream, - on_progress, - |progress| progress.result_commit, - "commit", - ) - .await -} - -/// Drains an FVS restore stream, forwarding every frame and requiring a terminal result. -async fn finish_restore( - stream: impl Stream>, - on_progress: impl FnMut(&FvsProgress), -) -> Result { - finish_stream( - stream, - on_progress, - |progress| progress.result_restore, - "restore", - ) - .await -} - -/// Consumes the FVS streaming protocol and extracts its terminal payload. -/// -/// Every frame, including the terminal frame, is forwarded to `on_progress`. -/// End-of-stream or a terminal frame without the expected payload is a protocol -/// error rather than successful completion. -async fn finish_stream( - stream: impl Stream>, - mut on_progress: impl FnMut(&FvsProgress), - mut result: impl FnMut(FvsProgress) -> Option, - operation: &'static str, -) -> Result { - futures_util::pin_mut!(stream); - while let Some(progress) = stream.try_next().await? { - on_progress(&progress); - if progress.done { - return result(progress).ok_or(FvsError::MissingStreamResult(operation).into()); - } - } - Err(FvsError::MissingStreamResult(operation).into()) -} - -#[cfg(test)] -mod fvs_tests { - use futures_util::stream; - - use super::*; - - #[test] - fn finish_commit_forwards_progress_and_returns_terminal_result() { - futures_lite::future::block_on(async { - let frames = [ - FvsProgress { - phase: "hashing".into(), - current: 1, - total: 2, - ..Default::default() - }, - FvsProgress { - phase: "indexing".into(), - current: -1, - total: -1, - ..Default::default() - }, - FvsProgress { - phase: "done".into(), - done: true, - result_commit: Some(Commit { - state_id: "checkpoint".into(), - ..Default::default() - }), - ..Default::default() - }, - ]; - let mut updates = Vec::new(); - - let commit = finish_commit(stream::iter(frames.map(Ok::<_, FvsError>)), |progress| { - updates.push(Transfer::from(progress)) - }) - .await - .unwrap(); - - assert_eq!( - updates, - [ - Transfer { - current: 1, - total: Some(2), - }, - Transfer { - current: 0, - total: None, - }, - Transfer { - current: 0, - total: None, - }, - ] - ); - assert_eq!(commit.state_id, "checkpoint"); - }); - } -}