Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ samples/ # Example apps (C#, C++, Node.js, Python, Rust)

### Update Flow

1. **`VelopackApp.Run()`** executes at app startup. It handles fast-exit lifecycle hooks invoked by the update binary (`--veloapp-install`, `--veloapp-updated`, `--veloapp-obsolete`, `--veloapp-uninstall` — each with a version arg). These hooks have strict time limits (15-30s) and the process is killed if exceeded. It also auto-applies pending updates if a newer local package exists, fires `OnFirstRun`/`OnRestarted` callbacks based on environment variables (`VELOPACK_FIRSTRUN`, `VELOPACK_RESTART`), and cleans up old packages.
1. **`VelopackApp.Run()`** executes at app startup. It handles fast-exit lifecycle hooks invoked by the update binary (`--veloapp-install`, `--veloapp-updated`, `--veloapp-obsolete`, `--veloapp-uninstall` — each with a version arg). The install hook has a 30-second limit and the uninstall hook has a 60-second limit. On Windows, the before-update and after-update hooks default to 60 seconds; set `VELOPACK_UPDATE_HOOK_TIMEOUT_SECONDS` to an integer from 1 to 300 to override them. The process is killed when a hook exceeds its limit. `VelopackApp.Run()` also auto-applies pending updates if a newer local package exists, fires `OnFirstRun`/`OnRestarted` callbacks based on environment variables (`VELOPACK_FIRSTRUN`, `VELOPACK_RESTART`), and cleans up old packages.

2. **`UpdateManager.CheckForUpdatesAsync()`** queries an `IUpdateSource` for the remote release feed, compares against the installed version, and builds a delta strategy. It selects deltas if: they exist, there are ≤10 in the chain, and their total size < the full package size. Returns `UpdateInfo` with `TargetFullRelease` and `DeltasToTarget[]`.

Expand Down
97 changes: 95 additions & 2 deletions src/bins/src/commands/apply.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
use crate::shared::{self, OperationWait};
use anyhow::{bail, Result};
use std::{ffi::OsString, path::PathBuf};
#[cfg(any(target_os = "windows", test))]
use std::time::Duration;
use velopack::{constants, locator, locator::VelopackLocator};

pub const DEFAULT_UPDATE_HOOK_TIMEOUT_SECS: u64 = 60;
pub const MAX_UPDATE_HOOK_TIMEOUT_SECS: u64 = 5 * 60;
pub const UPDATE_HOOK_TIMEOUT_ENV: &str = "VELOPACK_UPDATE_HOOK_TIMEOUT_SECONDS";
#[cfg(any(target_os = "windows", test))]
const ELEVATED_APPLY_NON_HOOK_BUDGET_SECS: u64 = 10 * 60;

#[cfg(target_os = "linux")]
use super::apply_linux_impl::apply_package_impl;
#[cfg(target_os = "macos")]
Expand All @@ -15,10 +23,17 @@ pub enum HookRunMode {
/// Do not run any hooks.
None,
/// Run all hooks (pre and post).
All,
All { timeout_secs: u64 },
/// Only run post-apply hooks (e.g. --veloapp-updated). Used during legacy migration
/// where the obsolete hook is irrelevant but the app needs to know it was updated.
PostOnly,
PostOnly { timeout_secs: u64 },
}

#[cfg(any(target_os = "windows", test))]
pub(crate) fn elevated_apply_wait_timeout(child_hook_timeout_secs: u64) -> Duration {
// Preserve the previous ten-minute wait as the budget for extraction and apply work,
// then account for both hooks because the elevated child always enters apply as All.
Duration::from_secs(ELEVATED_APPLY_NON_HOOK_BUDGET_SECS.saturating_add(child_hook_timeout_secs.saturating_mul(2)))
}

pub fn apply(
Expand Down Expand Up @@ -70,3 +85,81 @@ pub fn apply(
}
}
}

pub fn configured_update_hook_timeout_secs(cli_value: Option<u64>) -> u64 {
if let Some(value) = cli_value.filter(|value| is_valid_update_hook_timeout(*value)) {
return value;
}

let env_value = std::env::var(UPDATE_HOOK_TIMEOUT_ENV).ok();
resolve_update_hook_timeout_secs(None, env_value.as_deref())
}

fn resolve_update_hook_timeout_secs(cli_value: Option<u64>, env_value: Option<&str>) -> u64 {
if let Some(value) = cli_value.filter(|value| is_valid_update_hook_timeout(*value)) {
return value;
}

if let Some(value) = env_value {
match value.parse::<u64>() {
Ok(value) if is_valid_update_hook_timeout(value) => return value,
_ => warn!(
"Ignoring invalid {} value {:?}; expected an integer from 1 to {} seconds.",
UPDATE_HOOK_TIMEOUT_ENV, value, MAX_UPDATE_HOOK_TIMEOUT_SECS
),
}
}

DEFAULT_UPDATE_HOOK_TIMEOUT_SECS
}

fn is_valid_update_hook_timeout(value: u64) -> bool {
(1..=MAX_UPDATE_HOOK_TIMEOUT_SECS).contains(&value)
}

#[cfg(test)]
mod tests {
use super::{
elevated_apply_wait_timeout, resolve_update_hook_timeout_secs, DEFAULT_UPDATE_HOOK_TIMEOUT_SECS, MAX_UPDATE_HOOK_TIMEOUT_SECS,
};
use std::time::Duration;

#[test]
fn elevated_apply_wait_timeout_uses_default_child_hook_budget() {
let child_hook_timeout_secs = resolve_update_hook_timeout_secs(None, None);

assert_eq!(child_hook_timeout_secs, DEFAULT_UPDATE_HOOK_TIMEOUT_SECS);
assert_eq!(elevated_apply_wait_timeout(child_hook_timeout_secs), Duration::from_secs(720));
}

#[test]
fn elevated_apply_wait_timeout_uses_configured_child_hook_budget() {
let child_hook_timeout_secs = resolve_update_hook_timeout_secs(None, Some("120"));

assert_eq!(elevated_apply_wait_timeout(child_hook_timeout_secs), Duration::from_secs(840));
assert_eq!(elevated_apply_wait_timeout(MAX_UPDATE_HOOK_TIMEOUT_SECS), Duration::from_secs(1200));
}

#[test]
fn update_hook_timeout_defaults_to_one_minute() {
assert_eq!(resolve_update_hook_timeout_secs(None, None), DEFAULT_UPDATE_HOOK_TIMEOUT_SECS);
assert_eq!(DEFAULT_UPDATE_HOOK_TIMEOUT_SECS, 60);
}

#[test]
fn update_hook_timeout_accepts_environment_override() {
assert_eq!(resolve_update_hook_timeout_secs(None, Some("120")), 120);
}

#[test]
fn update_hook_timeout_cli_override_takes_precedence() {
assert_eq!(resolve_update_hook_timeout_secs(Some(90), Some("120")), 90);
}

#[test]
fn update_hook_timeout_rejects_zero_and_invalid_environment_values() {
assert_eq!(resolve_update_hook_timeout_secs(None, Some("0")), DEFAULT_UPDATE_HOOK_TIMEOUT_SECS);
assert_eq!(resolve_update_hook_timeout_secs(None, Some("301")), DEFAULT_UPDATE_HOOK_TIMEOUT_SECS);
assert_eq!(resolve_update_hook_timeout_secs(None, Some("not-a-number")), DEFAULT_UPDATE_HOOK_TIMEOUT_SECS);
}
}
36 changes: 25 additions & 11 deletions src/bins/src/commands/apply_windows_impl.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{dialogs, shared, windows};
use anyhow::{bail, Context, Result};
use std::{ffi::OsString, fs, path::PathBuf, time::Duration};
use std::{ffi::OsString, fs, path::PathBuf};
use velopack::{bundle::load_bundle_from_file, constants, locator::VelopackLocator, process};

// fn ropycopy<P1: AsRef<Path>, P2: AsRef<Path>>(source: &P1, dest: &P2) -> Result<()> {
Expand Down Expand Up @@ -46,6 +46,10 @@ fn remove_temp_dir_timed(path: &PathBuf) {

pub fn apply_package_impl(old_locator: &VelopackLocator, package: &PathBuf, hook_mode: super::HookRunMode) -> Result<VelopackLocator> {
let root_path = old_locator.get_root_dir();
let update_hook_timeout_secs = match hook_mode {
super::HookRunMode::None => None,
super::HookRunMode::All { timeout_secs } | super::HookRunMode::PostOnly { timeout_secs } => Some(timeout_secs),
};

let mut bundle = load_bundle_from_file(package).map_err(|e| {
warn!("Deleting package {:?} to prevent update loop: {}", package, e);
Expand All @@ -66,7 +70,7 @@ pub fn apply_package_impl(old_locator: &VelopackLocator, package: &PathBuf, hook
info!("Re-launching as administrator to update in {:?}", root_path);

let packages_dir = old_locator.get_packages_dir();
let args: Vec<OsString> = vec![
let mut args: Vec<OsString> = vec![
"apply".into(),
"--norestart".into(),
"--package".into(),
Expand All @@ -76,21 +80,30 @@ pub fn apply_package_impl(old_locator: &VelopackLocator, package: &PathBuf, hook
"--packageDir".into(),
packages_dir.into(),
];
let elevated_child_hook_timeout_secs =
update_hook_timeout_secs.unwrap_or_else(|| super::configured_update_hook_timeout_secs(None));
args.push("--hookTimeoutSeconds".into());
args.push(elevated_child_hook_timeout_secs.to_string().into());
let exe_path = std::env::current_exe()?;
let work_dir: Option<String> = None; // same as this process
// NB: show_window must be true for dialogs to be shown
// https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-taskdialogindirect#remarks
let process_handle = process::run_process_as_admin(&exe_path, args, work_dir, true)?;
let elevated_wait_timeout = super::elevated_apply_wait_timeout(elevated_child_hook_timeout_secs);

info!(
"Waiting (up to 10 minutes) for elevated process (pid: {}) to exit...",
"Waiting (up to {} seconds) for elevated process (pid: {}) to exit...",
elevated_wait_timeout.as_secs(),
process_handle.pid()
);
let result = process::wait_for_process_to_exit(process_handle, Some(Duration::from_secs(10 * 60)))?;
let result = process::wait_for_process_to_exit(process_handle, Some(elevated_wait_timeout))?;

match result {
process::WaitResult::WaitTimeout => {
bail!("Elevated process has not exited within 10 minutes. (TIMEOUT)");
bail!(
"Elevated process has not exited within {} seconds. (TIMEOUT)",
elevated_wait_timeout.as_secs()
);
}
process::WaitResult::ExitCode(code) => {
if code != 0 {
Expand Down Expand Up @@ -149,8 +162,8 @@ pub fn apply_package_impl(old_locator: &VelopackLocator, package: &PathBuf, hook
reporter.set_indeterminate();

// second, run application hooks (but don't care if it fails)
if hook_mode == super::HookRunMode::All {
crate::windows::run_hook(old_locator, constants::HOOK_CLI_OBSOLETE, 15);
if let super::HookRunMode::All { timeout_secs } = hook_mode {
crate::windows::run_hook(old_locator, constants::HOOK_CLI_OBSOLETE, timeout_secs);
} else {
info!("Skipping --veloapp-obsolete hook.");
}
Expand Down Expand Up @@ -215,10 +228,11 @@ pub fn apply_package_impl(old_locator: &VelopackLocator, package: &PathBuf, hook
}

// seventh, we run the post-install hooks
if hook_mode == super::HookRunMode::All || hook_mode == super::HookRunMode::PostOnly {
crate::windows::run_hook(&new_locator, constants::HOOK_CLI_UPDATED, 15);
} else {
info!("Skipping --veloapp-updated hook.");
match hook_mode {
super::HookRunMode::All { timeout_secs } | super::HookRunMode::PostOnly { timeout_secs } => {
crate::windows::run_hook(&new_locator, constants::HOOK_CLI_UPDATED, timeout_secs);
}
super::HookRunMode::None => info!("Skipping --veloapp-updated hook."),
}

// update application shortcuts
Expand Down
12 changes: 11 additions & 1 deletion src/bins/src/commands/start_windows_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,17 @@ fn try_legacy_migration(root_dir: &PathBuf, manifest: &Manifest) -> Result<Velop

info!("Applying latest full package...");
let buf = Path::new(&package.0).to_path_buf();
let new_locator = super::apply(&locator, false, OperationWait::NoWait, Some(&buf), None, super::HookRunMode::PostOnly)?;
let update_hook_timeout_secs = super::configured_update_hook_timeout_secs(None);
let new_locator = super::apply(
&locator,
false,
OperationWait::NoWait,
Some(&buf),
None,
super::HookRunMode::PostOnly {
timeout_secs: update_hook_timeout_secs,
},
)?;

info!("Removing old app-* folders...");
shared::delete_app_prefixed_folders(root_dir);
Expand Down
32 changes: 27 additions & 5 deletions src/bins/src/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ fn root_command() -> Command {
.arg(arg!(-w --wait "Wait for the parent process to terminate before applying the update").hide(true))
.arg(arg!(--waitPid <PID> "Wait for the specified process to terminate before applying the update").value_parser(value_parser!(u32)))
.arg(arg!(-p --package <FILE> "Update package to apply").value_parser(value_parser!(PathBuf)))
.arg(arg!(--hookTimeoutSeconds <SECONDS> "Maximum time allowed for before-update and after-update hooks").value_parser(value_parser!(u64).range(1..=commands::MAX_UPDATE_HOOK_TIMEOUT_SECS)))
.arg(arg!([EXE_ARGS] "Arguments to pass to the started executable. Must be preceded by '--'.").required(false).last(true).num_args(0..).value_parser(value_parser!(OsString)))
)
.subcommand(Command::new("start")
Expand Down Expand Up @@ -258,27 +259,38 @@ fn get_exe_args(matches: &ArgMatches) -> Option<Vec<OsString>> {
matches.get_many::<OsString>("EXE_ARGS").map(|v| v.map(|f| f.to_os_string()).collect())
}

fn get_apply_args(matches: &ArgMatches) -> (OperationWait, bool, Option<&PathBuf>, Option<Vec<OsString>>) {
fn get_apply_args(matches: &ArgMatches) -> (OperationWait, bool, Option<&PathBuf>, Option<Vec<OsString>>, u64) {
let restart = !get_flag_or_false(matches, "norestart");
let package = matches.get_one::<PathBuf>("package");
let exe_args = get_exe_args(matches);
let wait = get_op_wait(matches);
(wait, restart, package, exe_args)
let hook_timeout_secs = commands::configured_update_hook_timeout_secs(matches.get_one::<u64>("hookTimeoutSeconds").copied());
(wait, restart, package, exe_args, hook_timeout_secs)
}

fn apply(context: LocationContext, matches: &ArgMatches) -> Result<()> {
let (wait, restart, package, exe_args) = get_apply_args(matches);
let (wait, restart, package, exe_args, hook_timeout_secs) = get_apply_args(matches);
info!("Command: Apply");
info!(" Restart: {:?}", restart);
info!(" Wait: {:?}", wait);
info!(" Package: {:?}", package);
info!(" Exe Args: {:?}", exe_args);
info!(" Update Hook Timeout: {}s", hook_timeout_secs);

let locator = auto_locate_app_manifest(context)?;
// Note: lock is NOT acquired here. It's acquired inside apply_package_impl
// AFTER the self-elevation check, to avoid deadlock when the non-elevated
// parent spawns an elevated child (both would try to lock the same file).
let _ = commands::apply(&locator, restart, wait, package, exe_args, commands::HookRunMode::All)?;
let _ = commands::apply(
&locator,
restart,
wait,
package,
exe_args,
commands::HookRunMode::All {
timeout_secs: hook_timeout_secs,
},
)?;
Ok(())
}

Expand Down Expand Up @@ -363,14 +375,24 @@ fn test_cli_parse_handles_equals_spaces() {
"C:\\Some Path\\With = Spaces\\Package.zip",
];
let matches = try_parse_command_line_matches(command.iter().map(|s| s.to_string()).collect()).unwrap();
let (wait, restart, package, exe_args) = get_apply_args(matches.subcommand_matches("apply").unwrap());
let (wait, restart, package, exe_args, _) = get_apply_args(matches.subcommand_matches("apply").unwrap());

assert_eq!(wait, OperationWait::NoWait);
assert_eq!(restart, true);
assert_eq!(package, Some(&PathBuf::from("C:\\Some Path\\With = Spaces\\Package.zip")));
assert_eq!(exe_args, None);
}

#[test]
fn test_apply_cli_accepts_update_hook_timeout_override() {
let matches = root_command()
.try_get_matches_from(["Update", "apply", "--hookTimeoutSeconds", "90"])
.unwrap();
let (_, _, _, _, hook_timeout_secs) = get_apply_args(matches.subcommand_matches("apply").unwrap());

assert_eq!(hook_timeout_secs, 90);
}

#[cfg(target_os = "windows")]
#[test]
fn test_cli_handles_root_dir_at_end() {
Expand Down
6 changes: 4 additions & 2 deletions src/lib-cpp/include/Velopack.h
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,8 @@ void vpkc_app_set_hook_before_uninstall(vpkc_hook_callback_t cb_before_uninstall
* Sets a callback to be run before the app is updated.
* WARNING: FastCallback hooks are run during critical stages of Velopack operations.
* Your code will be run and then the process will exit.
* If your code has not completed within 30 seconds, it will be terminated.
* If your code has not completed within 60 seconds by default, it will be terminated.
* Set `VELOPACK_UPDATE_HOOK_TIMEOUT_SECONDS` to an integer from 1 through 300, inclusive, to override this timeout.
* Only supported on windows; On other operating systems, this will never be called.
* @param cb_before_update The callback to run before the app is updated. The callback takes a user data pointer and the version of the app as a string.
*/
Expand All @@ -564,7 +565,8 @@ void vpkc_app_set_hook_before_update(vpkc_hook_callback_t cb_before_update);
* Sets a callback to be run after the app is updated.
* WARNING: FastCallback hooks are run during critical stages of Velopack operations.
* Your code will be run and then the process will exit.
* If your code has not completed within 30 seconds, it will be terminated.
* If your code has not completed within 60 seconds by default, it will be terminated.
* Set `VELOPACK_UPDATE_HOOK_TIMEOUT_SECONDS` to an integer from 1 through 300, inclusive, to override this timeout.
* Only supported on windows; On other operating systems, this will never be called.
* @param cb_after_update The callback to run after the app is updated. The callback takes a user data pointer and the version of the app as a string.
*/
Expand Down
6 changes: 4 additions & 2 deletions src/lib-cpp/include/Velopack.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,8 @@ class VelopackApp {
* This hook is triggered before the app is updated.
* WARNING: This hook is run during critical stages of Velopack operations.
* Your code will be run and then the process will exit.
* If your code has not completed within 30 seconds, it will be terminated.
* If your code has not completed within 60 seconds by default, it will be terminated.
* Set `VELOPACK_UPDATE_HOOK_TIMEOUT_SECONDS` to an integer from 1 through 300, inclusive, to override this timeout.
* Only supported on windows; On other operating systems, this will never be called.
* @param cbBeforeUpdate The callback to run before the app is updated.
* @returns A reference to the builder.
Expand All @@ -708,7 +709,8 @@ class VelopackApp {
* This hook is triggered after the app is updated.
* WARNING: This hook is run during critical stages of Velopack operations.
* Your code will be run and then the process will exit.
* If your code has not completed within 30 seconds, it will be terminated.
* If your code has not completed within 60 seconds by default, it will be terminated.
* Set `VELOPACK_UPDATE_HOOK_TIMEOUT_SECONDS` to an integer from 1 through 300, inclusive, to override this timeout.
* Only supported on windows; On other operating systems, this will never be called.
* @param cbAfterUpdate The callback to run after the app is updated.
* @returns A reference to the builder.
Expand Down
Loading