From 27cc00794dc57980ee2edf94f0cf1f14a665774f Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Mon, 3 Aug 2026 00:39:59 -0400 Subject: [PATCH 1/3] docs(design): require LaunchAgent reload after activation --- DESIGN.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index f3eb70fe..e1c5863e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -605,7 +605,7 @@ The PV application update phase runs in this order: 5. Fetch and parse the PV app update manifest. 6. If the manifest version is not newer than the running version, report current, release the update lock, and continue to the Managed Resource phase in the same process. 7. Download, verify, install, and activate the newer app release. -8. Restart or kickstart the daemon without submitting `reconcile system`. +8. Reload the validated PV-owned LaunchAgent with tolerant bootout/bootstrap, then kickstart the daemon without submitting `reconcile system`. 9. Wait for daemon health. 10. Release the update lock and re-exec the active `~/.pv/bin/pv` into the internal Managed Resource continuation. @@ -625,7 +625,7 @@ User-facing `pv update` only performs PV application self-update when PV is runn `pv update` is version-driven. If the manifest version equals or is lower than the running version, it reports current and does not download, reinstall, or reactivate the same version. Downgrades are out of v1 scope. -Installed and self-updating PV uses the stable active symlink as the LaunchAgent program path: `~/.pv/bin/pv daemon:run`. During update preflight, a PV-owned stale LaunchAgent plist is normalized to that stable path. When an update later needs to restart the daemon after stale plist normalization, PV reloads the launchd job with bootout/bootstrap before kickstart so launchd uses the normalized `ProgramArguments`. A missing LaunchAgent fails before activation with guidance to run `pv setup` or `pv daemon:enable`. A non-PV-owned LaunchAgent fails before activation and is left unchanged. If LaunchAgent normalization succeeds but the manifest says the PV application is current, `pv update` exits zero without restarting the daemon. +Installed and self-updating PV uses the stable active symlink as the LaunchAgent program path: `~/.pv/bin/pv daemon:run`. During update preflight, a PV-owned stale LaunchAgent plist is normalized to that stable path. After every actual app release activation, PV reloads the validated PV-owned launchd job with tolerant bootout/bootstrap before kickstart so launchd resolves the active symlink again and uses the current `ProgramArguments`. This reload is required whether the plist was already current or was normalized during preflight. If post-activation health fails, rollback restores the previous active symlink and repeats the same bootout/bootstrap/kickstart sequence before checking daemon health. A missing LaunchAgent fails before activation with guidance to run `pv setup` or `pv daemon:enable`. A non-PV-owned LaunchAgent fails before activation and is left unchanged. If LaunchAgent normalization succeeds but the manifest says the PV application is current, `pv update` exits zero without restarting or reloading the daemon. PV app binary downloads use a freshly created command-scoped temporary file under `~/.pv/downloads/`; an existing path is never reused or truncated. While streaming the response, PV writes to that temporary file, computes SHA-256, and counts bytes. After the stream completes, PV verifies byte count and digest against the selected manifest asset. Verification failure deletes the temporary file, fails before app release installation, and leaves the active release unchanged. Successful installation uses the self-update release layout helper, then removes the temporary download; v1 does not introduce a persistent PV app binary download cache. From eb1a3adf3b97b5c34e6886bf1416080b9aa8d74b Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Mon, 3 Aug 2026 00:42:49 -0400 Subject: [PATCH 2/3] fix(cli): reload LaunchAgent after app activation --- crates/cli/src/commands/update.rs | 30 +++++++++++------------------- crates/cli/tests/update.rs | 18 ++++++++++++++++-- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/crates/cli/src/commands/update.rs b/crates/cli/src/commands/update.rs index 22c615c2..db176fd1 100644 --- a/crates/cli/src/commands/update.rs +++ b/crates/cli/src/commands/update.rs @@ -169,15 +169,15 @@ fn validate_active_release( fn normalize_launch_agent( environment: &impl Environment, paths: &PvPaths, -) -> Result { +) -> Result { let expected = launch_agent_config(paths); let path = launch_agent_path(environment)?; match platform::inspect_launch_agent_file(&path, Some(&expected)) { - LaunchAgentFileState::Current { .. } => Ok(LaunchAgentReload::NotRequired), + LaunchAgentFileState::Current { path, .. } => Ok(path), LaunchAgentFileState::Stale { .. } => { platform::write_launch_agent_file(&path, &expected)?; - Ok(LaunchAgentReload::Required { path }) + Ok(path) } LaunchAgentFileState::Missing { path } => Err(CliError::AppUpdateLaunchAgentMissing { path: path.to_string(), @@ -193,12 +193,6 @@ fn normalize_launch_agent( } } -#[derive(Clone, Debug)] -enum LaunchAgentReload { - NotRequired, - Required { path: Utf8PathBuf }, -} - fn launch_agent_config(paths: &PvPaths) -> LaunchAgentConfig { LaunchAgentConfig::new( paths.active_pv_binary(), @@ -287,13 +281,11 @@ fn remove_download(path: &Utf8Path) -> Result<(), ExecuteError> { fn restart_daemon_without_reconciliation( environment: &impl Environment, paths: &PvPaths, - reload: &LaunchAgentReload, + launch_agent_path: &Utf8Path, health_check: DaemonHealthCheck, ) -> Result<(), ExecuteError> { - if let LaunchAgentReload::Required { path } = reload { - bootout_launch_agent_if_loaded(environment)?; - environment.bootstrap_launch_agent(path)?; - } + bootout_launch_agent_if_loaded(environment)?; + environment.bootstrap_launch_agent(launch_agent_path)?; clear_daemon_startup_failure_marker(paths)?; environment.kickstart_launch_agent()?; wait_until_daemon_started(paths.clone(), health_check)?; @@ -484,7 +476,7 @@ fn run_app_update_phase( let layout = state::AppReleaseLayout::new(paths.clone()); let current_version = AppUpdateVersion::current()?; let previous_version = validate_active_release(&layout, ¤t_version)?; - let launch_agent_reload = normalize_launch_agent(environment, &paths)?; + let launch_agent_path = normalize_launch_agent(environment, &paths)?; let manifest = fetch_app_update_manifest(environment)?; if manifest.version() <= ¤t_version { @@ -523,7 +515,7 @@ fn run_app_update_phase( if let Err(error) = restart_daemon_without_reconciliation( environment, &paths, - &launch_agent_reload, + &launch_agent_path, DaemonHealthCheck::AcceptProtocolMismatch, ) { return rollback_app_update( @@ -531,7 +523,7 @@ fn run_app_update_phase( RollbackContext { paths: &paths, layout: &layout, - launch_agent_reload: &launch_agent_reload, + launch_agent_path: &launch_agent_path, }, RollbackVersions { previous: &previous_version, @@ -566,7 +558,7 @@ struct RollbackVersions<'a> { struct RollbackContext<'a> { paths: &'a PvPaths, layout: &'a state::AppReleaseLayout, - launch_agent_reload: &'a LaunchAgentReload, + launch_agent_path: &'a Utf8Path, } fn rollback_app_update( @@ -592,7 +584,7 @@ fn rollback_app_update( if let Err(rollback_error) = restart_daemon_without_reconciliation( environment, context.paths, - context.launch_agent_reload, + context.launch_agent_path, DaemonHealthCheck::RequireCompatibleProtocol, ) { output.line(&format!( diff --git a/crates/cli/tests/update.rs b/crates/cli/tests/update.rs index ef82bf0c..800e138a 100644 --- a/crates/cli/tests/update.rs +++ b/crates/cli/tests/update.rs @@ -548,7 +548,11 @@ mod update_tests { ); assert_eq!( environment.operations(), - vec![format!("kickstart {LAUNCH_AGENT_LABEL}")] + vec![ + format!("bootout {LAUNCH_AGENT_LABEL}"), + format!("bootstrap {}", launch_agent_path(&paths)), + format!("kickstart {LAUNCH_AGENT_LABEL}"), + ] ); assert_eq!( environment.execs(), @@ -724,6 +728,8 @@ mod update_tests { assert_eq!( environment.operations(), vec![ + format!("bootout {LAUNCH_AGENT_LABEL}"), + format!("bootstrap {}", launch_agent_path(&paths)), format!("kickstart {LAUNCH_AGENT_LABEL}"), "lock probe held".to_string(), ] @@ -1078,7 +1084,11 @@ mod update_tests { assert_eq!( environment.operations(), vec![ + format!("bootout {LAUNCH_AGENT_LABEL}"), + format!("bootstrap {}", launch_agent_path(&paths)), format!("kickstart {LAUNCH_AGENT_LABEL}"), + format!("bootout {LAUNCH_AGENT_LABEL}"), + format!("bootstrap {}", launch_agent_path(&paths)), format!("kickstart {LAUNCH_AGENT_LABEL}"), ] ); @@ -1137,7 +1147,11 @@ mod update_tests { assert_eq!(layout.active_release()?, Some("0.3.0".to_string())); assert_eq!( environment.operations(), - vec![format!("kickstart {LAUNCH_AGENT_LABEL}")] + vec![ + format!("bootout {LAUNCH_AGENT_LABEL}"), + format!("bootstrap {}", launch_agent_path(&paths)), + format!("kickstart {LAUNCH_AGENT_LABEL}"), + ] ); assert_eq!( daemon_requests, From 188d54ac22434b2fcbb51e99a46712feecf1303d Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Mon, 3 Aug 2026 00:43:46 -0400 Subject: [PATCH 3/3] test(cli): cover unloaded LaunchAgent update --- crates/cli/tests/update.rs | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/cli/tests/update.rs b/crates/cli/tests/update.rs index 800e138a..a330fd77 100644 --- a/crates/cli/tests/update.rs +++ b/crates/cli/tests/update.rs @@ -34,6 +34,7 @@ mod update_tests { operations: RefCell>, execs: RefCell)>>, exec_result: RefCell>, + launch_agent_unloaded: bool, delete_on_first_kickstart: RefCell>, lock_probe: RefCell>, startup_marker_on_first_kickstart: RefCell>, @@ -47,6 +48,7 @@ mod update_tests { operations: RefCell::new(Vec::new()), execs: RefCell::new(Vec::new()), exec_result: RefCell::new(Ok(ExitCode::SUCCESS)), + launch_agent_unloaded: false, delete_on_first_kickstart: RefCell::new(None), lock_probe: RefCell::new(None), startup_marker_on_first_kickstart: RefCell::new(None), @@ -66,6 +68,11 @@ mod update_tests { self } + fn with_unloaded_launch_agent(mut self) -> Self { + self.launch_agent_unloaded = true; + self + } + fn with_delete_on_first_kickstart(self, path: Utf8PathBuf) -> Self { self.delete_on_first_kickstart.replace(Some(path)); self @@ -148,6 +155,12 @@ mod update_tests { .borrow_mut() .push(format!("bootout {LAUNCH_AGENT_LABEL}")); + if self.launch_agent_unloaded { + return Err(platform::PlatformError::LaunchAgent( + "launch agent is not loaded".to_string(), + )); + } + Ok(()) } @@ -698,6 +711,45 @@ mod update_tests { Ok(()) } + #[test] + fn update_bootstraps_launch_agent_when_bootout_reports_already_unloaded() -> anyhow::Result<()> + { + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let paths = PvPaths::for_home(home.clone()); + state::fs::ensure_layout(&paths)?; + let layout = install_current_release(&paths)?; + write_launch_agent(&paths, &paths.active_pv_binary())?; + let daemon = FakeDaemon::start(&paths, vec![health_response()])?; + let environment = TestEnvironment::new( + &home, + ScriptedClient::new() + .with_text(&app_manifest( + "0.3.0", + APP_BINARY_SHA256, + u64::try_from(APP_BINARY.len())?, + )) + .with_download(APP_BINARY), + ) + .with_unloaded_launch_agent(); + + let output = run_pv(&["update"], &environment)?; + let _daemon_requests = daemon.join()?; + + assert_eq!(output.exit_code, ExitCode::SUCCESS); + assert_eq!(layout.active_release()?, Some("0.3.0".to_string())); + assert_eq!( + environment.operations(), + vec![ + format!("bootout {LAUNCH_AGENT_LABEL}"), + format!("bootstrap {}", launch_agent_path(&paths)), + format!("kickstart {LAUNCH_AGENT_LABEL}"), + ] + ); + + Ok(()) + } + #[test] fn update_holds_lock_until_daemon_transition_finishes() -> anyhow::Result<()> { let tempdir = tempdir()?;