mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9337bd557b | ||
|
|
162a31ae92 | ||
|
|
2f84234a31 |
@@ -38,7 +38,7 @@ jobs:
|
|||||||
run: cargo test --workspace --locked
|
run: cargo test --workspace --locked
|
||||||
|
|
||||||
windows-daemon:
|
windows-daemon:
|
||||||
name: Windows daemon regression tests
|
name: Windows daemon and update regression tests
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -52,6 +52,12 @@ jobs:
|
|||||||
- name: Run Windows process liveness tests
|
- name: Run Windows process liveness tests
|
||||||
run: cargo test -p bsk --lib --locked daemon::lockfile::tests
|
run: cargo test -p bsk --lib --locked daemon::lockfile::tests
|
||||||
|
|
||||||
|
- name: Run Windows update tests
|
||||||
|
run: cargo test -p bsk --lib --locked cli::update::tests
|
||||||
|
|
||||||
|
- name: Run Windows update process tests
|
||||||
|
run: cargo test -p bsk --test windows_update --locked
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend lint, typecheck, tests, build
|
name: Frontend lint, typecheck, tests, build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
use std::os::windows::process::CommandExt;
|
mod windows;
|
||||||
#[cfg(windows)]
|
|
||||||
use std::process::{Command, Stdio};
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use clap::Args;
|
use clap::Args;
|
||||||
@@ -363,7 +361,8 @@ fn install_candidate_with_client(
|
|||||||
crate::daemon::start::run_stop().context("stop bsk daemon before update")?;
|
crate::daemon::start::run_stop().context("stop bsk daemon before update")?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let action = replace_binary_for_update(&target, &binary, daemon_was_running)?;
|
let restart_args = daemon_was_running.then(StartArgs::default);
|
||||||
|
let action = replace_binary_for_update(&target, &binary, restart_args.as_ref())?;
|
||||||
|
|
||||||
if daemon_was_running && matches!(action, InstallAction::Replaced) {
|
if daemon_was_running && matches!(action, InstallAction::Replaced) {
|
||||||
crate::daemon::start::run_start(StartArgs::default())
|
crate::daemon::start::run_start(StartArgs::default())
|
||||||
@@ -394,8 +393,8 @@ pub(crate) fn download_candidate_binary(
|
|||||||
|
|
||||||
/// Daemon-side install: download, verify, and replace the executable at
|
/// Daemon-side install: download, verify, and replace the executable at
|
||||||
/// `target` (on Windows: stage the replacement next to it). Unlike the
|
/// `target` (on Windows: stage the replacement next to it). Unlike the
|
||||||
/// CLI path this never stops or starts the daemon — the daemon drives
|
/// CLI path this never stops the daemon. On Windows the helper starts
|
||||||
/// its own restart once the binary is replaced.
|
/// its replacement after exit; elsewhere the daemon drives its own restart.
|
||||||
///
|
///
|
||||||
/// `target` must be captured *before* any replacement happens: on Linux
|
/// `target` must be captured *before* any replacement happens: on Linux
|
||||||
/// `std::env::current_exe` starts returning a ` (deleted)`-suffixed
|
/// `std::env::current_exe` starts returning a ` (deleted)`-suffixed
|
||||||
@@ -403,10 +402,11 @@ pub(crate) fn download_candidate_binary(
|
|||||||
pub(crate) fn self_install_candidate(
|
pub(crate) fn self_install_candidate(
|
||||||
candidate: &UpdateCandidate,
|
candidate: &UpdateCandidate,
|
||||||
target: &Path,
|
target: &Path,
|
||||||
|
restart_args: &StartArgs,
|
||||||
) -> Result<InstallAction> {
|
) -> Result<InstallAction> {
|
||||||
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
|
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
|
||||||
let binary = download_candidate_binary(candidate, &client)?;
|
let binary = download_candidate_binary(candidate, &client)?;
|
||||||
replace_binary_at_path(target, &binary)
|
replace_binary_for_update(target, &binary, Some(restart_args))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome of one daemon auto-update step (see [`auto_update_step`]).
|
/// Outcome of one daemon auto-update step (see [`auto_update_step`]).
|
||||||
@@ -423,8 +423,8 @@ pub(crate) enum AutoUpdateOutcome {
|
|||||||
/// The new binary replaced the old one; the daemon should now
|
/// The new binary replaced the old one; the daemon should now
|
||||||
/// restart into it.
|
/// restart into it.
|
||||||
Replaced { latest: String },
|
Replaced { latest: String },
|
||||||
/// Windows staged the replacement next to the running binary; a
|
/// Windows started a helper that will replace the executable and
|
||||||
/// daemon/terminal restart finishes it.
|
/// restart the daemon once the current process exits.
|
||||||
Staged { latest: String },
|
Staged { latest: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,6 +668,7 @@ fn render_report(format: Format, report: &UpdateReport) -> Result<()> {
|
|||||||
}
|
}
|
||||||
if matches!(report.install_action, Some("staged")) {
|
if matches!(report.install_action, Some("staged")) {
|
||||||
println!("the detached update helper will apply the replacement after exit");
|
println!("the detached update helper will apply the replacement after exit");
|
||||||
|
println!("if it fails, see the *.update-*.log file next to the executable");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Format::Json => {
|
Format::Json => {
|
||||||
@@ -688,22 +689,22 @@ pub fn extract_bsk_binary(archive_bytes: &[u8], kind: ArchiveKind) -> Result<Vec
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> {
|
pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> {
|
||||||
replace_binary_for_update(target, binary, false)
|
replace_binary_for_update(target, binary, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replace_binary_for_update(
|
fn replace_binary_for_update(
|
||||||
target: &Path,
|
target: &Path,
|
||||||
binary: &[u8],
|
binary: &[u8],
|
||||||
restart_daemon: bool,
|
restart_args: Option<&StartArgs>,
|
||||||
) -> Result<InstallAction> {
|
) -> Result<InstallAction> {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
stage_windows_replacement(target, binary, restart_daemon)
|
stage_windows_replacement(target, binary, restart_args)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(not(windows))]
|
||||||
{
|
{
|
||||||
let _ = restart_daemon;
|
let _ = restart_args;
|
||||||
replace_binary_atomically(target, binary)
|
replace_binary_atomically(target, binary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -749,34 +750,69 @@ fn replace_binary_atomically(target: &Path, binary: &[u8]) -> Result<InstallActi
|
|||||||
fn stage_windows_replacement(
|
fn stage_windows_replacement(
|
||||||
target: &Path,
|
target: &Path,
|
||||||
binary: &[u8],
|
binary: &[u8],
|
||||||
restart_daemon: bool,
|
restart_args: Option<&StartArgs>,
|
||||||
) -> Result<InstallAction> {
|
) -> Result<InstallAction> {
|
||||||
|
// The helper owns replacement/restart after this process exits. Do not
|
||||||
|
// report Staged until it has actually begun executing the script.
|
||||||
|
let _child = spawn_windows_replacement(target, binary, restart_args, 120)?;
|
||||||
|
Ok(InstallAction::Staged)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn spawn_windows_replacement(
|
||||||
|
target: &Path,
|
||||||
|
binary: &[u8],
|
||||||
|
restart_args: Option<&StartArgs>,
|
||||||
|
attempts: u32,
|
||||||
|
) -> Result<windows::Helper> {
|
||||||
let paths = staged_replacement_paths(target, std::process::id())?;
|
let paths = staged_replacement_paths(target, std::process::id())?;
|
||||||
|
let ready_path = paths.script_path.with_extension("ready");
|
||||||
|
let log_path = paths.script_path.with_extension("log");
|
||||||
|
let result = (|| {
|
||||||
|
// A previous failed attempt by this process must not acknowledge a
|
||||||
|
// new helper. All paths are private to this target and process id.
|
||||||
|
match std::fs::remove_file(&ready_path) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
}
|
||||||
std::fs::write(&paths.binary_path, binary)
|
std::fs::write(&paths.binary_path, binary)
|
||||||
.with_context(|| format!("write {}", paths.binary_path.display()))?;
|
.with_context(|| format!("write {}", paths.binary_path.display()))?;
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&paths.script_path,
|
&paths.script_path,
|
||||||
windows_replacement_script(target, &paths.binary_path, restart_daemon),
|
windows_replacement_script(restart_args, attempts),
|
||||||
)
|
)
|
||||||
.with_context(|| format!("write {}", paths.script_path.display()))?;
|
.with_context(|| format!("write {}", paths.script_path.display()))?;
|
||||||
|
let mut child = windows::spawn(
|
||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
&paths.script_path,
|
||||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
&paths.binary_path,
|
||||||
let command = format!("\"{}\"", paths.script_path.display());
|
target,
|
||||||
if let Err(err) = Command::new("cmd.exe")
|
&ready_path,
|
||||||
.args(["/D", "/S", "/C"])
|
&log_path,
|
||||||
.arg(command)
|
)
|
||||||
.stdin(Stdio::null())
|
.context("launch detached Windows update helper")?;
|
||||||
.stdout(Stdio::null())
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
.stderr(Stdio::null())
|
loop {
|
||||||
.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)
|
if std::fs::remove_file(&ready_path).is_ok() {
|
||||||
.spawn()
|
return Ok(child);
|
||||||
{
|
}
|
||||||
|
if let Some(status) = child.try_wait()? {
|
||||||
|
bail!("Windows update helper exited before becoming ready: {status}");
|
||||||
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
bail!("Windows update helper did not become ready within 5 seconds");
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
let _ = std::fs::remove_file(&paths.binary_path);
|
let _ = std::fs::remove_file(&paths.binary_path);
|
||||||
let _ = std::fs::remove_file(&paths.script_path);
|
let _ = std::fs::remove_file(&paths.script_path);
|
||||||
return Err(err).context("launch detached Windows update helper");
|
let _ = std::fs::remove_file(&ready_path);
|
||||||
}
|
}
|
||||||
Ok(InstallAction::Staged)
|
result.with_context(|| format!("Windows update helper failed; see {}", log_path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> {
|
pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> {
|
||||||
@@ -793,25 +829,43 @@ pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplace
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(windows, test))]
|
#[cfg(any(windows, test))]
|
||||||
fn windows_replacement_script(target: &Path, staged_binary: &Path, restart_daemon: bool) -> String {
|
fn windows_replacement_script(restart_args: Option<&StartArgs>, attempts: u32) -> String {
|
||||||
let target = target.display().to_string().replace('%', "%%");
|
// Only fixed switches and numeric values go into the ASCII script. Paths
|
||||||
let staged_binary = staged_binary.display().to_string().replace('%', "%%");
|
// stay in Unicode environment variables, including %, ! and shell symbols.
|
||||||
let restart = if restart_daemon {
|
let restart = restart_args.map_or_else(String::new, |args| {
|
||||||
format!("start \"\" /B \"{target}\" daemon start >nul 2>nul\r\n")
|
format!(
|
||||||
} else {
|
"\"%BSK_UPDATE_TARGET%\" daemon start --port {} --session-idle {}ms --daemon-idle {}ms\r\nif errorlevel 1 goto failed_restart\r\n",
|
||||||
String::new()
|
args.resolved_port(),
|
||||||
};
|
args.resolved_session_idle().as_millis(),
|
||||||
|
args.resolved_daemon_idle().as_millis(),
|
||||||
|
)
|
||||||
|
});
|
||||||
format!(
|
format!(
|
||||||
"@echo off\r\n\
|
"@echo off\r\n\
|
||||||
setlocal\r\n\
|
setlocal DisableDelayedExpansion\r\n\
|
||||||
|
> \"%BSK_UPDATE_READY%\" echo ready\r\n\
|
||||||
|
if errorlevel 1 exit /b 1\r\n\
|
||||||
|
set /a attempts=0\r\n\
|
||||||
:retry\r\n\
|
:retry\r\n\
|
||||||
move /Y \"{staged_binary}\" \"{target}\" >nul 2>nul\r\n\
|
move /Y \"%BSK_UPDATE_SOURCE%\" \"%BSK_UPDATE_TARGET%\" >nul\r\n\
|
||||||
if errorlevel 1 (\r\n\
|
if not errorlevel 1 goto replaced\r\n\
|
||||||
|
set /a attempts+=1\r\n\
|
||||||
|
if %attempts% geq {attempts} goto failed_replace\r\n\
|
||||||
|
if not exist \"%BSK_UPDATE_SOURCE%\" goto failed_replace\r\n\
|
||||||
ping -n 2 127.0.0.1 >nul\r\n\
|
ping -n 2 127.0.0.1 >nul\r\n\
|
||||||
goto retry\r\n\
|
goto retry\r\n\
|
||||||
)\r\n\
|
:failed_replace\r\n\
|
||||||
|
echo Failed to replace the executable after %attempts% attempts.\r\n\
|
||||||
|
goto failed\r\n\
|
||||||
|
:replaced\r\n\
|
||||||
{restart}\
|
{restart}\
|
||||||
del /F /Q \"%~f0\" >nul 2>nul\r\n"
|
del /F /Q \"%BSK_UPDATE_LOG%\" >nul 2>nul\r\n\
|
||||||
|
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 0)\r\n\
|
||||||
|
:failed_restart\r\n\
|
||||||
|
echo Replaced the executable but failed to restart the daemon.\r\n\
|
||||||
|
:failed\r\n\
|
||||||
|
del /F /Q \"%BSK_UPDATE_SOURCE%\" >nul 2>nul\r\n\
|
||||||
|
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 1)\r\n"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,6 +1019,7 @@ mod tests {
|
|||||||
assert_eq!(extracted, b"windows binary");
|
assert_eq!(extracted, b"windows binary");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
#[test]
|
#[test]
|
||||||
fn replaces_binary_at_path() {
|
fn replaces_binary_at_path() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
@@ -999,31 +1054,139 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn windows_replacement_script_retries_restarts_and_cleans_itself() {
|
fn windows_replacement_script_preserves_restart_config() {
|
||||||
let script = windows_replacement_script(
|
let args = StartArgs {
|
||||||
Path::new(r"C:\Program Files\bsk.exe"),
|
port: Some(54321),
|
||||||
Path::new(r"C:\Program Files\bsk.exe.update-42"),
|
session_idle: Some(Duration::from_millis(1234)),
|
||||||
true,
|
daemon_idle: Some(Duration::from_secs(75)),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let script = windows_replacement_script(Some(&args), 120);
|
||||||
|
assert!(script.is_ascii());
|
||||||
|
assert!(
|
||||||
|
script
|
||||||
|
.contains("daemon start --port 54321 --session-idle 1234ms --daemon-idle 75000ms")
|
||||||
);
|
);
|
||||||
|
assert!(!windows_replacement_script(None, 120).contains("daemon start"));
|
||||||
assert!(script.contains(":retry"));
|
|
||||||
assert!(script.contains("move /Y"));
|
|
||||||
assert!(script.contains("ping -n 2 127.0.0.1"));
|
|
||||||
assert!(!script.contains("timeout"));
|
|
||||||
assert!(script.contains(r#"start "" /B "C:\Program Files\bsk.exe" daemon start"#));
|
|
||||||
assert!(script.contains(r#"del /F /Q "%~f0""#));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[cfg(windows)]
|
||||||
fn windows_replacement_script_omits_restart_when_daemon_was_not_running() {
|
fn wait_for_helper(mut child: windows::Helper) -> std::process::ExitStatus {
|
||||||
let script = windows_replacement_script(
|
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||||
Path::new(r"C:\bsk.exe"),
|
loop {
|
||||||
Path::new(r"C:\bsk.exe.update-42"),
|
if let Some(status) = child.try_wait().unwrap() {
|
||||||
false,
|
return status;
|
||||||
);
|
}
|
||||||
|
if std::time::Instant::now() >= deadline {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
panic!("Windows update helper did not exit");
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
assert!(!script.contains("daemon start"));
|
#[cfg(windows)]
|
||||||
assert!(script.contains(r#"del /F /Q "%~f0""#));
|
#[test]
|
||||||
|
fn windows_helper_replaces_in_unicode_spaces_and_shell_symbol_paths() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
for name in [
|
||||||
|
"ascii",
|
||||||
|
"with space",
|
||||||
|
"中文目录",
|
||||||
|
"literal %PATH% ! & (folder)",
|
||||||
|
] {
|
||||||
|
let dir = tmp.path().join(name);
|
||||||
|
std::fs::create_dir(&dir).unwrap();
|
||||||
|
let target = dir.join("bsk.exe");
|
||||||
|
std::fs::write(&target, b"old binary").unwrap();
|
||||||
|
let child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
|
||||||
|
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
|
||||||
|
let status = wait_for_helper(child);
|
||||||
|
assert!(
|
||||||
|
status.success(),
|
||||||
|
"{name}: {:?}",
|
||||||
|
std::fs::read_to_string(paths.script_path.with_extension("log"))
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"new binary", "{name}");
|
||||||
|
// Successful updates leave no helper, staged binary, ready file or log.
|
||||||
|
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "{name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn windows_helper_waits_for_unlock_then_replaces() {
|
||||||
|
use std::os::windows::fs::OpenOptionsExt;
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let target = tmp.path().join("bsk.exe");
|
||||||
|
std::fs::write(&target, b"old binary").unwrap();
|
||||||
|
// Deny delete sharing, like a running Windows executable.
|
||||||
|
let lock = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.share_mode(1)
|
||||||
|
.open(&target)
|
||||||
|
.unwrap();
|
||||||
|
let mut child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(150));
|
||||||
|
assert!(child.try_wait().unwrap().is_none());
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
|
||||||
|
drop(lock);
|
||||||
|
assert!(wait_for_helper(child).success());
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"new binary");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn windows_helper_bounds_retries_and_preserves_old_binary_on_failure() {
|
||||||
|
use std::os::windows::fs::OpenOptionsExt;
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let target = tmp.path().join("bsk.exe");
|
||||||
|
std::fs::write(&target, b"old binary").unwrap();
|
||||||
|
let _lock = std::fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.share_mode(1)
|
||||||
|
.open(&target)
|
||||||
|
.unwrap();
|
||||||
|
let child = spawn_windows_replacement(&target, b"new binary", None, 2).unwrap();
|
||||||
|
assert!(!wait_for_helper(child).success());
|
||||||
|
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
|
||||||
|
assert!(!paths.binary_path.exists());
|
||||||
|
assert!(!paths.script_path.exists());
|
||||||
|
let log_bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
|
||||||
|
let log = String::from_utf8_lossy(&log_bytes);
|
||||||
|
assert!(
|
||||||
|
log.contains("Failed to replace the executable after 2 attempts."),
|
||||||
|
"{log}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[test]
|
||||||
|
fn windows_helper_reports_restart_failure_without_losing_replacement() {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let target = tmp.path().join("bsk.exe");
|
||||||
|
std::fs::write(&target, b"old binary").unwrap();
|
||||||
|
// Not a valid PE executable: replacement succeeds but restart fails.
|
||||||
|
let child = spawn_windows_replacement(
|
||||||
|
&target,
|
||||||
|
b"invalid executable",
|
||||||
|
Some(&StartArgs::default()),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!wait_for_helper(child).success());
|
||||||
|
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
|
||||||
|
let bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
|
||||||
|
let log = String::from_utf8_lossy(&bytes);
|
||||||
|
assert!(
|
||||||
|
log.contains("Replaced the executable but failed to restart the daemon."),
|
||||||
|
"{log}"
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(&target).unwrap(), b"invalid executable");
|
||||||
|
assert!(!paths.script_path.exists());
|
||||||
|
assert!(!paths.binary_path.exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1282,10 +1445,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn auto_update_step_staged_outcome_requests_no_restart() {
|
fn auto_update_step_reports_staged_handoff() {
|
||||||
// The Windows shape: the replacement is staged next to the
|
// The helper, rather than this process, starts the new executable.
|
||||||
// running binary, so the outcome must not ask for the immediate
|
// The daemon must exit to let that staged handoff finish.
|
||||||
// self-restart that `Replaced` triggers.
|
|
||||||
let candidate = test_candidate();
|
let candidate = test_candidate();
|
||||||
let outcome =
|
let outcome =
|
||||||
auto_update_step(Some(&candidate), true, 0, |_| Ok(InstallAction::Staged)).unwrap();
|
auto_update_step(Some(&candidate), true, 0, |_| Ok(InstallAction::Staged)).unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
//! Launch the update helper with only its stdio handles inherited.
|
||||||
|
//!
|
||||||
|
//! std::process::Command on Windows also inherits other inheritable handles.
|
||||||
|
//! A daemon's listening socket must not survive in the helper: it would keep
|
||||||
|
//! the port occupied after the daemon exits and prevent its replacement starting.
|
||||||
|
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io;
|
||||||
|
use std::os::windows::ffi::OsStrExt;
|
||||||
|
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
|
||||||
|
use std::os::windows::process::ExitStatusExt;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::ExitStatus;
|
||||||
|
|
||||||
|
use windows_sys::Win32::Foundation::{
|
||||||
|
HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT,
|
||||||
|
};
|
||||||
|
use windows_sys::Win32::System::Threading::{
|
||||||
|
CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW,
|
||||||
|
DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess,
|
||||||
|
InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROCESS_INFORMATION,
|
||||||
|
STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute,
|
||||||
|
WaitForSingleObject,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) struct Helper(OwnedHandle);
|
||||||
|
|
||||||
|
impl Helper {
|
||||||
|
pub(super) fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
|
||||||
|
// SAFETY: the owned process handle remains valid for both calls.
|
||||||
|
match unsafe { WaitForSingleObject(self.0.as_raw_handle(), 0) } {
|
||||||
|
WAIT_OBJECT_0 => {
|
||||||
|
let mut code = 0;
|
||||||
|
if unsafe { GetExitCodeProcess(self.0.as_raw_handle(), &mut code) } == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(Some(ExitStatus::from_raw(code)))
|
||||||
|
}
|
||||||
|
WAIT_TIMEOUT => Ok(None),
|
||||||
|
_ => Err(io::Error::last_os_error()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn kill(&mut self) -> io::Result<()> {
|
||||||
|
// SAFETY: this handle belongs to the helper we created.
|
||||||
|
if unsafe { TerminateProcess(self.0.as_raw_handle(), 1) } == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn wait(&mut self) -> io::Result<()> {
|
||||||
|
// Only used after kill(); do not leave a live helper on startup failure.
|
||||||
|
if unsafe { WaitForSingleObject(self.0.as_raw_handle(), 5000) } != WAIT_OBJECT_0 {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::TimedOut,
|
||||||
|
"update helper did not exit",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The opaque attribute list needs pointer-aligned storage and explicit teardown.
|
||||||
|
struct AttributeList(Vec<usize>);
|
||||||
|
|
||||||
|
impl Drop for AttributeList {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: this wrapper is constructed only after successful initialization.
|
||||||
|
unsafe { DeleteProcThreadAttributeList(self.0.as_mut_ptr().cast()) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn spawn(
|
||||||
|
script: &Path,
|
||||||
|
source: &Path,
|
||||||
|
target: &Path,
|
||||||
|
ready: &Path,
|
||||||
|
log_path: &Path,
|
||||||
|
) -> io::Result<Helper> {
|
||||||
|
let root = std::env::var_os("SystemRoot")
|
||||||
|
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "SystemRoot is not set"))?;
|
||||||
|
let application = wide(Path::new(&root).join("System32/cmd.exe").as_os_str());
|
||||||
|
// /S /C strips one outer pair of quotes. Environment expansion keeps paths
|
||||||
|
// Unicode and avoids CRT escaping, batch-file encoding, and CALL expansion.
|
||||||
|
let mut command = wide(OsStr::new(
|
||||||
|
"cmd.exe /D /V:OFF /S /C \"\"%BSK_UPDATE_SCRIPT%\"\"",
|
||||||
|
));
|
||||||
|
let overrides = [
|
||||||
|
("BSK_UPDATE_SCRIPT", script.as_os_str()),
|
||||||
|
("BSK_UPDATE_SOURCE", source.as_os_str()),
|
||||||
|
("BSK_UPDATE_TARGET", target.as_os_str()),
|
||||||
|
("BSK_UPDATE_READY", ready.as_os_str()),
|
||||||
|
("BSK_UPDATE_LOG", log_path.as_os_str()),
|
||||||
|
];
|
||||||
|
let mut env: Vec<_> = std::env::vars_os()
|
||||||
|
.filter(|(key, _)| {
|
||||||
|
let key = key.to_string_lossy();
|
||||||
|
!overrides
|
||||||
|
.iter()
|
||||||
|
.any(|(name, _)| key.eq_ignore_ascii_case(name))
|
||||||
|
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMONIZED_ENV)
|
||||||
|
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMON_REPLACEMENT_WAIT_ENV)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
env.extend(
|
||||||
|
overrides
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value)| (key.into(), value.to_owned())),
|
||||||
|
);
|
||||||
|
env.sort_by_key(|(key, _)| key.to_string_lossy().to_uppercase());
|
||||||
|
let mut environment = Vec::new();
|
||||||
|
for (key, value) in env {
|
||||||
|
environment.extend(key.encode_wide());
|
||||||
|
environment.push(b'=' as u16);
|
||||||
|
environment.extend(value.encode_wide());
|
||||||
|
environment.push(0);
|
||||||
|
}
|
||||||
|
environment.push(0);
|
||||||
|
|
||||||
|
let input = File::open("NUL")?;
|
||||||
|
let log = File::create(log_path)?;
|
||||||
|
let handles: [HANDLE; 2] = [input.as_raw_handle(), log.as_raw_handle()];
|
||||||
|
for &handle in &handles {
|
||||||
|
// SAFETY: these are dedicated helper stdio handles, held until spawn
|
||||||
|
// finishes. The explicit handle list excludes every other parent handle.
|
||||||
|
if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut size = 0;
|
||||||
|
// SAFETY: the first call queries the allocation size, as required by Win32.
|
||||||
|
unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut size) };
|
||||||
|
if size == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let mut storage = vec![0usize; size.div_ceil(std::mem::size_of::<usize>())];
|
||||||
|
// SAFETY: storage has the requested size and pointer alignment.
|
||||||
|
if unsafe { InitializeProcThreadAttributeList(storage.as_mut_ptr().cast(), 1, 0, &mut size) }
|
||||||
|
== 0
|
||||||
|
{
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
let mut attributes = AttributeList(storage);
|
||||||
|
// SAFETY: the handle array and attribute storage outlive CreateProcessW.
|
||||||
|
if unsafe {
|
||||||
|
UpdateProcThreadAttribute(
|
||||||
|
attributes.0.as_mut_ptr().cast(),
|
||||||
|
0,
|
||||||
|
PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize,
|
||||||
|
handles.as_ptr().cast(),
|
||||||
|
std::mem::size_of_val(&handles),
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null(),
|
||||||
|
)
|
||||||
|
} == 0
|
||||||
|
{
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// SAFETY: these Win32 structs permit zero initialization; required fields
|
||||||
|
// are populated below before the process is created.
|
||||||
|
let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
|
||||||
|
startup.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
|
||||||
|
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
|
||||||
|
startup.StartupInfo.hStdInput = handles[0];
|
||||||
|
startup.StartupInfo.hStdOutput = handles[1];
|
||||||
|
startup.StartupInfo.hStdError = handles[1];
|
||||||
|
startup.lpAttributeList = attributes.0.as_mut_ptr().cast();
|
||||||
|
let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
|
||||||
|
// SAFETY: strings are NUL-terminated UTF-16, the environment is double-NUL
|
||||||
|
// terminated, and all buffers/handles remain alive for the duration of spawn.
|
||||||
|
if unsafe {
|
||||||
|
CreateProcessW(
|
||||||
|
application.as_ptr(),
|
||||||
|
command.as_mut_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
std::ptr::null(),
|
||||||
|
1,
|
||||||
|
CREATE_NO_WINDOW
|
||||||
|
| CREATE_NEW_PROCESS_GROUP
|
||||||
|
| CREATE_UNICODE_ENVIRONMENT
|
||||||
|
| EXTENDED_STARTUPINFO_PRESENT,
|
||||||
|
environment.as_ptr().cast(),
|
||||||
|
std::ptr::null(),
|
||||||
|
&startup.StartupInfo,
|
||||||
|
&mut process,
|
||||||
|
)
|
||||||
|
} == 0
|
||||||
|
{
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
// SAFETY: successful CreateProcessW transfers these two handles to us.
|
||||||
|
let _thread = unsafe { OwnedHandle::from_raw_handle(process.hThread) };
|
||||||
|
Ok(Helper(unsafe {
|
||||||
|
OwnedHandle::from_raw_handle(process.hProcess)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wide(value: &OsStr) -> Vec<u16> {
|
||||||
|
value.encode_wide().chain(Some(0)).collect()
|
||||||
|
}
|
||||||
@@ -232,8 +232,8 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
|
|||||||
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
|
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
|
||||||
let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state));
|
let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state));
|
||||||
// Fired by the update check task after a successful auto-update:
|
// Fired by the update check task after a successful auto-update:
|
||||||
// the replacement daemon has already been spawned, so this
|
// the replacement daemon (or Windows update helper) is ready, so
|
||||||
// process should shut down and let it take over.
|
// this process should shut down and let it take over.
|
||||||
let restart_notify = Arc::new(tokio::sync::Notify::new());
|
let restart_notify = Arc::new(tokio::sync::Notify::new());
|
||||||
let update_check_task =
|
let update_check_task =
|
||||||
spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify));
|
spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify));
|
||||||
@@ -539,8 +539,8 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task:
|
|||||||
/// task spawns the replacement daemon (see
|
/// task spawns the replacement daemon (see
|
||||||
/// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process
|
/// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process
|
||||||
/// shuts down and the new version takes over; on Windows the
|
/// shuts down and the new version takes over; on Windows the
|
||||||
/// replacement can only be staged, so it just logs that a restart is
|
/// helper waits for this process to exit, replaces the binary, and starts
|
||||||
/// needed.
|
/// the new daemon with the same configuration.
|
||||||
pub(crate) fn spawn_update_check_task(
|
pub(crate) fn spawn_update_check_task(
|
||||||
state: Arc<DaemonState>,
|
state: Arc<DaemonState>,
|
||||||
restart: Arc<tokio::sync::Notify>,
|
restart: Arc<tokio::sync::Notify>,
|
||||||
@@ -605,7 +605,11 @@ pub(crate) fn spawn_update_check_task(
|
|||||||
|candidate| {
|
|candidate| {
|
||||||
let target =
|
let target =
|
||||||
exe_path.as_deref().context("current executable unknown")?;
|
exe_path.as_deref().context("current executable unknown")?;
|
||||||
update::self_install_candidate(candidate, target)
|
update::self_install_candidate(
|
||||||
|
candidate,
|
||||||
|
target,
|
||||||
|
&restart_start_args(&state.config),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -635,10 +639,11 @@ pub(crate) fn spawn_update_check_task(
|
|||||||
sessions,
|
sessions,
|
||||||
"auto-update postponed: agent session(s) active; will retry on the next tick"
|
"auto-update postponed: agent session(s) active; will retry on the next tick"
|
||||||
),
|
),
|
||||||
update::AutoUpdateOutcome::Staged { latest } => warn!(
|
update::AutoUpdateOutcome::Staged { latest } => {
|
||||||
%latest,
|
info!(%latest, "update helper ready; exiting so it can replace and restart the daemon");
|
||||||
"auto-update staged the new binary but cannot replace the running daemon in place; restart the daemon (or terminal) to finish the upgrade"
|
restart.notify_one();
|
||||||
),
|
return;
|
||||||
|
}
|
||||||
update::AutoUpdateOutcome::Replaced { latest } => {
|
update::AutoUpdateOutcome::Replaced { latest } => {
|
||||||
info!(
|
info!(
|
||||||
current = env!("CARGO_PKG_VERSION"),
|
current = env!("CARGO_PKG_VERSION"),
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
//! Exercise self-update with a real executable and a local release server.
|
||||||
|
#![cfg(windows)]
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{Cursor, Read, Write};
|
||||||
|
use std::net::{TcpListener, TcpStream};
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use bsk::daemon::info::DaemonInfo;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
const MARKER: &[u8] = b"windows-update-regression-fixture";
|
||||||
|
|
||||||
|
struct ReleaseServer {
|
||||||
|
url: String,
|
||||||
|
requests: Arc<AtomicUsize>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
worker: Option<thread::JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReleaseServer {
|
||||||
|
fn new(binary: &[u8]) -> Self {
|
||||||
|
let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||||
|
archive
|
||||||
|
.start_file(
|
||||||
|
"bsk.exe",
|
||||||
|
zip::write::SimpleFileOptions::default()
|
||||||
|
.compression_method(zip::CompressionMethod::Stored),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
archive.write_all(binary).unwrap();
|
||||||
|
let archive = archive.finish().unwrap().into_inner();
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
listener.set_nonblocking(true).unwrap();
|
||||||
|
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
|
let manifest = serde_json::to_vec(&serde_json::json!({
|
||||||
|
"version": "999.0.0",
|
||||||
|
"assets": {"windows-x64": {
|
||||||
|
"url": format!("{url}/bsk.zip"),
|
||||||
|
"sha256": Sha256::digest(&archive).iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
|
||||||
|
}},
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let stop = Arc::new(AtomicBool::new(false));
|
||||||
|
let worker_stop = Arc::clone(&stop);
|
||||||
|
let requests = Arc::new(AtomicUsize::new(0));
|
||||||
|
let worker_requests = Arc::clone(&requests);
|
||||||
|
let worker = thread::spawn(move || {
|
||||||
|
while !worker_stop.load(Ordering::SeqCst) {
|
||||||
|
let (mut stream, _) = match listener.accept() {
|
||||||
|
Ok(connection) => connection,
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
|
||||||
|
thread::sleep(Duration::from_millis(10));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(err) => panic!("accept release request: {err}"),
|
||||||
|
};
|
||||||
|
// Windows accept() inherits the listener's nonblocking mode.
|
||||||
|
// Keep accept polling for shutdown, but read/write each request
|
||||||
|
// in blocking mode with the bounded timeouts below.
|
||||||
|
stream.set_nonblocking(false).unwrap();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
stream
|
||||||
|
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
let mut request = Vec::new();
|
||||||
|
let mut chunk = [0; 1024];
|
||||||
|
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||||
|
let len = stream.read(&mut chunk).unwrap();
|
||||||
|
if len == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
request.extend_from_slice(&chunk[..len]);
|
||||||
|
}
|
||||||
|
let body = if request.starts_with(b"GET /bsk.zip ") {
|
||||||
|
worker_requests.fetch_add(1, Ordering::SeqCst);
|
||||||
|
&archive
|
||||||
|
} else {
|
||||||
|
&manifest
|
||||||
|
};
|
||||||
|
write!(
|
||||||
|
stream,
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
stream.write_all(body).unwrap();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Self {
|
||||||
|
url: format!("{url}/version.json"),
|
||||||
|
requests,
|
||||||
|
stop,
|
||||||
|
worker: Some(worker),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ReleaseServer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
let _ = self.worker.take().unwrap().join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_server_waits_for_delayed_and_fragmented_request_headers() {
|
||||||
|
let server = ReleaseServer::new(b"test binary");
|
||||||
|
let address = server
|
||||||
|
.url
|
||||||
|
.strip_prefix("http://")
|
||||||
|
.unwrap()
|
||||||
|
.strip_suffix("/version.json")
|
||||||
|
.unwrap();
|
||||||
|
let mut stream = TcpStream::connect(address).unwrap();
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
stream
|
||||||
|
.set_write_timeout(Some(Duration::from_secs(5)))
|
||||||
|
.unwrap();
|
||||||
|
// Give accept() time to run before any bytes arrive, then split the
|
||||||
|
// headers across writes. Both reads must wait rather than return WouldBlock.
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
stream.write_all(b"GET /version.json HTTP/1.1\r\n").unwrap();
|
||||||
|
thread::sleep(Duration::from_millis(100));
|
||||||
|
stream.write_all(b"Host: localhost\r\n\r\n").unwrap();
|
||||||
|
let mut response = String::new();
|
||||||
|
stream.read_to_string(&mut response).unwrap();
|
||||||
|
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "{response}");
|
||||||
|
let (_, body) = response.split_once("\r\n\r\n").unwrap();
|
||||||
|
let manifest: serde_json::Value = serde_json::from_str(body).unwrap();
|
||||||
|
assert_eq!(manifest["version"], "999.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Fixture {
|
||||||
|
_tmp: tempfile::TempDir,
|
||||||
|
exe: PathBuf,
|
||||||
|
home: PathBuf,
|
||||||
|
binary: Vec<u8>,
|
||||||
|
server: ReleaseServer,
|
||||||
|
daemon: Option<Child>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let dir = tmp.path().join("中文 space %PATH% ! & (update)");
|
||||||
|
fs::create_dir(&dir).unwrap();
|
||||||
|
let exe = dir.join("bsk.exe");
|
||||||
|
let home = tmp.path().join("home");
|
||||||
|
fs::create_dir(&home).unwrap();
|
||||||
|
fs::copy(env!("CARGO_BIN_EXE_bsk"), &exe).unwrap();
|
||||||
|
// A PE overlay distinguishes the replacement without requiring a
|
||||||
|
// second build or changing the executable's behavior/version.
|
||||||
|
let mut binary = fs::read(&exe).unwrap();
|
||||||
|
binary.extend_from_slice(MARKER);
|
||||||
|
let server = ReleaseServer::new(&binary);
|
||||||
|
Self {
|
||||||
|
_tmp: tmp,
|
||||||
|
exe,
|
||||||
|
home,
|
||||||
|
binary,
|
||||||
|
server,
|
||||||
|
daemon: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command(&self) -> Command {
|
||||||
|
let mut cmd = Command::new(&self.exe);
|
||||||
|
cmd.env("BSK_HOME", &self.home)
|
||||||
|
.env("BSK_UPDATE_MANIFEST_URL", &self.server.url)
|
||||||
|
.env("BSK_AUTO_UPDATE", "off")
|
||||||
|
.env("RUST_LOG", "info")
|
||||||
|
.env("NO_PROXY", "127.0.0.1,localhost")
|
||||||
|
.env_remove("BSK_DAEMONIZED")
|
||||||
|
.env_remove("BSK_DAEMON_REPLACES_PID")
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.creation_flags(0x0800_0000);
|
||||||
|
cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_for(&self, description: &str, mut check: impl FnMut() -> bool) {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if check() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
let diagnostics: Vec<_> = fs::read_dir(self.exe.parent().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "log"))
|
||||||
|
.map(|entry| {
|
||||||
|
(
|
||||||
|
entry.path(),
|
||||||
|
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
|
||||||
|
.into_owned(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let home_logs: Vec<_> = fs::read_dir(&self.home)
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| entry.file_name().to_string_lossy().contains("log"))
|
||||||
|
.map(|entry| {
|
||||||
|
(
|
||||||
|
entry.path(),
|
||||||
|
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
|
||||||
|
.into_owned(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
panic!(
|
||||||
|
"timed out waiting for {description}; helper logs: {diagnostics:?}; daemon logs: {home_logs:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info(&self) -> Option<DaemonInfo> {
|
||||||
|
serde_json::from_slice(&fs::read(self.home.join("daemon.json")).ok()?).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn updated(&self) -> bool {
|
||||||
|
// A locked executable may briefly reject reads during replacement.
|
||||||
|
fs::read(&self.exe).is_ok_and(|binary| binary == self.binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Stop only the daemon belonging to this isolated BSK_HOME. Also reap
|
||||||
|
// the foreground process on assertion failures to avoid leaking locks.
|
||||||
|
let _ = self.command().args(["daemon", "stop"]).output();
|
||||||
|
if let Some(child) = self.daemon.as_mut() {
|
||||||
|
let _ = child.kill();
|
||||||
|
let _ = child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_update_replaces_the_running_executable_after_cli_exit() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
let out = fixture
|
||||||
|
.command()
|
||||||
|
.args(["--json", "update", "--yes"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||||
|
assert_eq!(report["status"], "staged");
|
||||||
|
fixture.wait_for("CLI replacement and helper cleanup", || {
|
||||||
|
fixture.updated() && fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
fixture.info().is_none(),
|
||||||
|
"an update must not start a previously absent daemon"
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.server.requests.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn automatic_update_exits_old_daemon_and_restarts_on_the_same_port() {
|
||||||
|
let mut fixture = Fixture::new();
|
||||||
|
let port = unused_port();
|
||||||
|
let log = fs::File::create(fixture.home.join("foreground.log")).unwrap();
|
||||||
|
let child = fixture
|
||||||
|
.command()
|
||||||
|
.env("BSK_AUTO_UPDATE", "on")
|
||||||
|
// Exercise the environment inherited by a normally detached daemon.
|
||||||
|
.env("BSK_DAEMONIZED", "1")
|
||||||
|
.args([
|
||||||
|
"daemon",
|
||||||
|
"start",
|
||||||
|
"--port",
|
||||||
|
&port.to_string(),
|
||||||
|
"--session-idle",
|
||||||
|
"1234ms",
|
||||||
|
"--daemon-idle",
|
||||||
|
"30s",
|
||||||
|
])
|
||||||
|
.stdout(Stdio::from(log.try_clone().unwrap()))
|
||||||
|
.stderr(Stdio::from(log))
|
||||||
|
.spawn()
|
||||||
|
.unwrap();
|
||||||
|
let old_pid = child.id();
|
||||||
|
fixture.daemon = Some(child);
|
||||||
|
fixture.wait_for("replacement daemon", || {
|
||||||
|
fixture.updated()
|
||||||
|
&& fixture
|
||||||
|
.info()
|
||||||
|
.is_some_and(|info| info.pid != old_pid && info.ws_port == port)
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
fixture
|
||||||
|
.daemon
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.try_wait()
|
||||||
|
.unwrap()
|
||||||
|
.is_some(),
|
||||||
|
"old daemon must exit"
|
||||||
|
);
|
||||||
|
fixture.wait_for("helper cleanup", || {
|
||||||
|
fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
|
||||||
|
});
|
||||||
|
let status = fixture
|
||||||
|
.command()
|
||||||
|
.args(["--json", "status"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
status.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&status.stderr)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fixture.server.requests.load(Ordering::SeqCst),
|
||||||
|
1,
|
||||||
|
"the replacement must not stage another update immediately"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unused_port() -> u16 {
|
||||||
|
TcpListener::bind("127.0.0.1:0")
|
||||||
|
.unwrap()
|
||||||
|
.local_addr()
|
||||||
|
.unwrap()
|
||||||
|
.port()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user