mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0822c53c65 | ||
|
|
caea98cd52 | ||
|
|
e8137b42cc | ||
|
|
cb5dc148d5 | ||
|
|
3c5f838c56 | ||
|
|
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, installer and cancellation regression tests
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -52,6 +52,20 @@ 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 and cancellation process tests
|
||||||
|
run: cargo test -p bsk --test windows_update --test windows_parent_cancel --locked
|
||||||
|
|
||||||
|
- name: Run installer tests (Windows PowerShell 5.1)
|
||||||
|
shell: powershell
|
||||||
|
run: ./scripts/install-windows.test.ps1
|
||||||
|
|
||||||
|
- name: Run installer tests (PowerShell 7)
|
||||||
|
shell: pwsh
|
||||||
|
run: ./scripts/install-windows.test.ps1
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
name: Frontend lint, typecheck, tests, build
|
name: Frontend lint, typecheck, tests, build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -73,6 +73,14 @@ where
|
|||||||
P: Serialize + Send + 'static,
|
P: Serialize + Send + 'static,
|
||||||
R: DeserializeOwned + Send + 'static,
|
R: DeserializeOwned + Send + 'static,
|
||||||
{
|
{
|
||||||
|
let parent_cancel = stdin_cancellation();
|
||||||
|
if parent_cancel.as_ref().is_some_and(|rx| *rx.borrow()) {
|
||||||
|
return Err(CliError::from_rpc(RpcError {
|
||||||
|
code: ErrorCode::Cancelled,
|
||||||
|
message: "parent closed the cancellation pipe".into(),
|
||||||
|
data: Some(serde_json::json!({"reason": "cancelled_before_dispatch"})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
let rpc_id: RpcId = format!("{}-{}", rpc_id_prefix, random_short_id());
|
let rpc_id: RpcId = format!("{}-{}", rpc_id_prefix, random_short_id());
|
||||||
let mut client = IpcClient::connect(sock.clone()).await?;
|
let mut client = IpcClient::connect(sock.clone()).await?;
|
||||||
let rpc_id_for_call = rpc_id.clone();
|
let rpc_id_for_call = rpc_id.clone();
|
||||||
@@ -88,16 +96,16 @@ where
|
|||||||
let outcome = tokio::select! {
|
let outcome = tokio::select! {
|
||||||
biased;
|
biased;
|
||||||
res = &mut main_fut => res?,
|
res = &mut main_fut => res?,
|
||||||
sig = wait_for_sigint() => {
|
sig = wait_for_cancel(parent_cancel) => {
|
||||||
sig.context("install SIGINT handler").map_err(CliError::Local)?;
|
sig.context("wait for cancellation").map_err(CliError::Local)?;
|
||||||
debug!(rpc_id = %rpc_id_for_cancel, "SIGINT: forwarding cancel to daemon");
|
debug!(rpc_id = %rpc_id_for_cancel, "forwarding cancel to daemon");
|
||||||
let _ = send_cancel(&sock, &rpc_id_for_cancel).await;
|
let _ = send_cancel(&sock, &rpc_id_for_cancel).await;
|
||||||
match tokio::time::timeout(CANCEL_RESPONSE_GRACE, &mut main_fut).await {
|
match tokio::time::timeout(CANCEL_RESPONSE_GRACE, &mut main_fut).await {
|
||||||
Ok(res) => res?,
|
Ok(res) => res?,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
debug!(
|
debug!(
|
||||||
rpc_id = %rpc_id_for_cancel,
|
rpc_id = %rpc_id_for_cancel,
|
||||||
"SIGINT: cancel grace elapsed; synthesising cancelled error"
|
"cancel grace elapsed; synthesising cancelled error"
|
||||||
);
|
);
|
||||||
return Err(CliError::from_rpc(RpcError {
|
return Err(CliError::from_rpc(RpcError {
|
||||||
code: ErrorCode::Cancelled,
|
code: ErrorCode::Cancelled,
|
||||||
@@ -112,6 +120,46 @@ where
|
|||||||
outcome.map_err(CliError::from_rpc)
|
outcome.map_err(CliError::from_rpc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Release caller-owned staging even after cancellation. This deliberately
|
||||||
|
/// exposes only transfer.release, not a general bypass for business RPCs.
|
||||||
|
/// The entire batch, including connection setup, shares one bounded budget.
|
||||||
|
pub fn release_transfers<'a>(
|
||||||
|
sock: &Path,
|
||||||
|
transfer_ids: impl IntoIterator<Item = &'a str>,
|
||||||
|
) -> Result<(), CliError> {
|
||||||
|
use bsk_protocol::tools::{TransferIdParams, TransferReleaseResult};
|
||||||
|
const CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
let ids: Vec<_> = transfer_ids.into_iter().collect();
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.context("build runtime for transfer cleanup")?;
|
||||||
|
rt.block_on(async {
|
||||||
|
tokio::time::timeout(CLEANUP_TIMEOUT, async {
|
||||||
|
let mut client = IpcClient::connect(sock).await?;
|
||||||
|
for id in ids {
|
||||||
|
client
|
||||||
|
.call::<_, TransferReleaseResult>(
|
||||||
|
"transfer-release",
|
||||||
|
Method::TransferRelease,
|
||||||
|
Some(TransferIdParams {
|
||||||
|
transfer_id: id.to_string(),
|
||||||
|
}),
|
||||||
|
CLEANUP_TIMEOUT,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.map_err(CliError::from_rpc)?;
|
||||||
|
}
|
||||||
|
Ok::<_, CliError>(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("transfer cleanup exceeded its total time budget")?
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Send a `cancel { rpc_id }` frame over a fresh connection so it
|
/// Send a `cancel { rpc_id }` frame over a fresh connection so it
|
||||||
/// lands on the daemon while the original call is still parked.
|
/// lands on the daemon while the original call is still parked.
|
||||||
///
|
///
|
||||||
@@ -146,8 +194,57 @@ fn random_short_id() -> String {
|
|||||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wait for either Ctrl-C or process SIGTERM. Returning here means
|
/// Opt-in control pipe for parent processes that cannot send Ctrl-C (Node on
|
||||||
/// the user has asked us to stop.
|
/// Windows). One reader survives the short-lived runtimes used by multi-RPC
|
||||||
|
/// commands such as upload. Ordinary CLI stdin and terminal Ctrl-C are unchanged.
|
||||||
|
fn stdin_cancellation() -> Option<tokio::sync::watch::Receiver<bool>> {
|
||||||
|
if std::env::var("BSK_CANCEL_ON_STDIN_CLOSE").as_deref() != Ok("1") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
static CANCEL: std::sync::OnceLock<tokio::sync::watch::Receiver<bool>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
Some(
|
||||||
|
CANCEL
|
||||||
|
.get_or_init(|| {
|
||||||
|
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut stdin = std::io::stdin().lock();
|
||||||
|
let mut buf = [0; 64];
|
||||||
|
loop {
|
||||||
|
match stdin.read(&mut buf) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = tx.send(true);
|
||||||
|
});
|
||||||
|
rx
|
||||||
|
})
|
||||||
|
.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_cancel(
|
||||||
|
parent_cancel: Option<tokio::sync::watch::Receiver<bool>>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let parent_closed = async move {
|
||||||
|
match parent_cancel {
|
||||||
|
Some(mut rx) => {
|
||||||
|
let _ = rx.wait_for(|closed| *closed).await;
|
||||||
|
}
|
||||||
|
None => std::future::pending::<()>().await,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::select! {
|
||||||
|
result = wait_for_sigint() => result,
|
||||||
|
_ = parent_closed => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait for terminal Ctrl-C.
|
||||||
async fn wait_for_sigint() -> anyhow::Result<()> {
|
async fn wait_for_sigint() -> anyhow::Result<()> {
|
||||||
tokio::signal::ctrl_c().await.context("listen for SIGINT")?;
|
tokio::signal::ctrl_c().await.context("listen for SIGINT")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ use anyhow::Context;
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use bsk_protocol::Method;
|
use bsk_protocol::Method;
|
||||||
use bsk_protocol::tools::{
|
use bsk_protocol::tools::{
|
||||||
DownloadParams, DownloadResult, TransferChunkParams, TransferChunkResult, TransferIdParams,
|
DownloadParams, DownloadResult, TransferChunkParams, TransferChunkResult,
|
||||||
TransferReleaseResult,
|
|
||||||
};
|
};
|
||||||
use clap::Args;
|
use clap::Args;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -70,13 +69,7 @@ pub fn dispatch(args: DownloadArgs, format: Format) -> Result<(), CliError> {
|
|||||||
CliError::Local(anyhow::anyhow!("daemon returned no download transfer id"))
|
CliError::Local(anyhow::anyhow!("daemon returned no download transfer id"))
|
||||||
})?;
|
})?;
|
||||||
let write_result = write_transfer(&info.sock_path, &transfer_id, &args.out, args.overwrite);
|
let write_result = write_transfer(&info.sock_path, &transfer_id, &args.out, args.overwrite);
|
||||||
let _: Result<TransferReleaseResult, CliError> = crate::cli::business_rpc::call(
|
let _ = crate::cli::business_rpc::release_transfers(&info.sock_path, [transfer_id.as_str()]);
|
||||||
info.sock_path,
|
|
||||||
"transfer-release",
|
|
||||||
Method::TransferRelease,
|
|
||||||
Some(TransferIdParams { transfer_id }),
|
|
||||||
Duration::from_secs(5),
|
|
||||||
);
|
|
||||||
write_result?;
|
write_result?;
|
||||||
match format {
|
match format {
|
||||||
Format::Json => {
|
Format::Json => {
|
||||||
|
|||||||
@@ -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> {
|
||||||
let paths = staged_replacement_paths(target, std::process::id())?;
|
// The helper owns replacement/restart after this process exits. Do not
|
||||||
std::fs::write(&paths.binary_path, binary)
|
// report Staged until it has actually begun executing the script.
|
||||||
.with_context(|| format!("write {}", paths.binary_path.display()))?;
|
let _child = spawn_windows_replacement(target, binary, restart_args, 120)?;
|
||||||
std::fs::write(
|
Ok(InstallAction::Staged)
|
||||||
&paths.script_path,
|
}
|
||||||
windows_replacement_script(target, &paths.binary_path, restart_daemon),
|
|
||||||
)
|
|
||||||
.with_context(|| format!("write {}", paths.script_path.display()))?;
|
|
||||||
|
|
||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
#[cfg(windows)]
|
||||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
|
fn spawn_windows_replacement(
|
||||||
let command = format!("\"{}\"", paths.script_path.display());
|
target: &Path,
|
||||||
if let Err(err) = Command::new("cmd.exe")
|
binary: &[u8],
|
||||||
.args(["/D", "/S", "/C"])
|
restart_args: Option<&StartArgs>,
|
||||||
.arg(command)
|
attempts: u32,
|
||||||
.stdin(Stdio::null())
|
) -> Result<windows::Helper> {
|
||||||
.stdout(Stdio::null())
|
let paths = staged_replacement_paths(target, std::process::id())?;
|
||||||
.stderr(Stdio::null())
|
let ready_path = paths.script_path.with_extension("ready");
|
||||||
.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)
|
let log_path = paths.script_path.with_extension("log");
|
||||||
.spawn()
|
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)
|
||||||
|
.with_context(|| format!("write {}", paths.binary_path.display()))?;
|
||||||
|
std::fs::write(
|
||||||
|
&paths.script_path,
|
||||||
|
windows_replacement_script(restart_args, attempts),
|
||||||
|
)
|
||||||
|
.with_context(|| format!("write {}", paths.script_path.display()))?;
|
||||||
|
let mut child = windows::spawn(
|
||||||
|
&paths.script_path,
|
||||||
|
&paths.binary_path,
|
||||||
|
target,
|
||||||
|
&ready_path,
|
||||||
|
&log_path,
|
||||||
|
)
|
||||||
|
.context("launch detached Windows update helper")?;
|
||||||
|
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
if std::fs::remove_file(&ready_path).is_ok() {
|
||||||
|
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\
|
||||||
ping -n 2 127.0.0.1 >nul\r\n\
|
set /a attempts+=1\r\n\
|
||||||
goto retry\r\n\
|
if %attempts% geq {attempts} goto failed_replace\r\n\
|
||||||
)\r\n\
|
if not exist \"%BSK_UPDATE_SOURCE%\" goto failed_replace\r\n\
|
||||||
|
ping -n 2 127.0.0.1 >nul\r\n\
|
||||||
|
goto retry\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()
|
||||||
|
}
|
||||||
@@ -10,8 +10,7 @@ use base64::Engine;
|
|||||||
use bsk_protocol::Method;
|
use bsk_protocol::Method;
|
||||||
use bsk_protocol::tools::{
|
use bsk_protocol::tools::{
|
||||||
TransferBeginParams, TransferBeginResult, TransferChunkParams, TransferChunkResult,
|
TransferBeginParams, TransferBeginResult, TransferChunkParams, TransferChunkResult,
|
||||||
TransferIdParams, TransferReadyResult, TransferReleaseResult, UploadFile, UploadMode,
|
TransferIdParams, TransferReadyResult, UploadFile, UploadMode, UploadParams, UploadResult,
|
||||||
UploadParams, UploadResult,
|
|
||||||
};
|
};
|
||||||
use clap::{Args, ValueEnum};
|
use clap::{Args, ValueEnum};
|
||||||
|
|
||||||
@@ -69,14 +68,12 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
|
|||||||
let (ref_, selector) = split_target(args.target, args.ref_, args.selector)?;
|
let (ref_, selector) = split_target(args.target, args.ref_, args.selector)?;
|
||||||
let mut staged = Vec::new();
|
let mut staged = Vec::new();
|
||||||
for path in &args.files {
|
for path in &args.files {
|
||||||
match stage_file(&info.sock_path, &args.session, path) {
|
if let Err(err) = stage_file(&info.sock_path, &args.session, path, &mut staged) {
|
||||||
Ok(file) => staged.push(file),
|
let _ = crate::cli::business_rpc::release_transfers(
|
||||||
Err(err) => {
|
&info.sock_path,
|
||||||
for (id, _) in &staged {
|
staged.iter().map(|(id, _)| id.as_str()),
|
||||||
let _ = release(&info.sock_path, id);
|
);
|
||||||
}
|
return Err(err);
|
||||||
return Err(err);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let params = UploadParams {
|
let params = UploadParams {
|
||||||
@@ -102,6 +99,17 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
|
|||||||
Some(params),
|
Some(params),
|
||||||
ipc_timeout(args.timeout),
|
ipc_timeout(args.timeout),
|
||||||
);
|
);
|
||||||
|
// The helper can reject cancellation before handing any request to the
|
||||||
|
// daemon. In that case staging still belongs to this CLI.
|
||||||
|
if result.as_ref().is_err_and(|err| {
|
||||||
|
err.data()
|
||||||
|
.is_some_and(|data| data["reason"] == "cancelled_before_dispatch")
|
||||||
|
}) {
|
||||||
|
let _ = crate::cli::business_rpc::release_transfers(
|
||||||
|
&info.sock_path,
|
||||||
|
staged.iter().map(|(id, _)| id.as_str()),
|
||||||
|
);
|
||||||
|
}
|
||||||
// Once tool.upload is dispatched, staging ownership belongs to the
|
// Once tool.upload is dispatched, staging ownership belongs to the
|
||||||
// session. A transport timeout cannot prove that Chrome did not attach
|
// session. A transport timeout cannot prove that Chrome did not attach
|
||||||
// the file, so releasing here could invalidate a late successful attach.
|
// the file, so releasing here could invalidate a late successful attach.
|
||||||
@@ -118,7 +126,12 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stage_file(sock: &Path, session: &str, path: &PathBuf) -> Result<(String, String), CliError> {
|
fn stage_file(
|
||||||
|
sock: &Path,
|
||||||
|
session: &str,
|
||||||
|
path: &PathBuf,
|
||||||
|
staged: &mut Vec<(String, String)>,
|
||||||
|
) -> Result<(), CliError> {
|
||||||
let mut file = File::open(path)
|
let mut file = File::open(path)
|
||||||
.with_context(|| format!("open upload file {}", path.display()))
|
.with_context(|| format!("open upload file {}", path.display()))
|
||||||
.map_err(CliError::Local)?;
|
.map_err(CliError::Local)?;
|
||||||
@@ -149,55 +162,39 @@ fn stage_file(sock: &Path, session: &str, path: &PathBuf) -> Result<(String, Str
|
|||||||
}),
|
}),
|
||||||
Duration::from_secs(10),
|
Duration::from_secs(10),
|
||||||
)?;
|
)?;
|
||||||
let staged = (|| {
|
// Record ownership immediately so one error path releases both completed
|
||||||
let mut offset = 0u64;
|
// files and the current partially staged file.
|
||||||
let mut buf = vec![0u8; begin.chunk_size as usize];
|
staged.push((begin.transfer_id.clone(), name));
|
||||||
loop {
|
let mut offset = 0u64;
|
||||||
let n = file.read(&mut buf).map_err(|e| CliError::Local(e.into()))?;
|
let mut buf = vec![0u8; begin.chunk_size as usize];
|
||||||
if n == 0 {
|
loop {
|
||||||
break;
|
let n = file.read(&mut buf).map_err(|e| CliError::Local(e.into()))?;
|
||||||
}
|
if n == 0 {
|
||||||
let reply: TransferChunkResult = crate::cli::business_rpc::call(
|
break;
|
||||||
sock.to_path_buf(),
|
|
||||||
"transfer-chunk",
|
|
||||||
Method::TransferChunk,
|
|
||||||
Some(TransferChunkParams {
|
|
||||||
transfer_id: begin.transfer_id.clone(),
|
|
||||||
offset,
|
|
||||||
data_base64: base64::engine::general_purpose::STANDARD.encode(&buf[..n]),
|
|
||||||
}),
|
|
||||||
Duration::from_secs(30),
|
|
||||||
)?;
|
|
||||||
offset = reply.next_offset;
|
|
||||||
}
|
}
|
||||||
let _: TransferReadyResult = crate::cli::business_rpc::call(
|
let reply: TransferChunkResult = crate::cli::business_rpc::call(
|
||||||
sock.to_path_buf(),
|
sock.to_path_buf(),
|
||||||
"transfer-finish",
|
"transfer-chunk",
|
||||||
Method::TransferFinish,
|
Method::TransferChunk,
|
||||||
Some(TransferIdParams {
|
Some(TransferChunkParams {
|
||||||
transfer_id: begin.transfer_id.clone(),
|
transfer_id: begin.transfer_id.clone(),
|
||||||
|
offset,
|
||||||
|
data_base64: base64::engine::general_purpose::STANDARD.encode(&buf[..n]),
|
||||||
}),
|
}),
|
||||||
Duration::from_secs(10),
|
Duration::from_secs(30),
|
||||||
)?;
|
)?;
|
||||||
Ok::<_, CliError>(())
|
offset = reply.next_offset;
|
||||||
})();
|
|
||||||
if let Err(err) = staged {
|
|
||||||
let _ = release(sock, &begin.transfer_id);
|
|
||||||
return Err(err);
|
|
||||||
}
|
}
|
||||||
Ok((begin.transfer_id, name))
|
let _: TransferReadyResult = crate::cli::business_rpc::call(
|
||||||
}
|
|
||||||
|
|
||||||
fn release(sock: &Path, id: &str) -> Result<TransferReleaseResult, CliError> {
|
|
||||||
crate::cli::business_rpc::call(
|
|
||||||
sock.to_path_buf(),
|
sock.to_path_buf(),
|
||||||
"transfer-release",
|
"transfer-finish",
|
||||||
Method::TransferRelease,
|
Method::TransferFinish,
|
||||||
Some(TransferIdParams {
|
Some(TransferIdParams {
|
||||||
transfer_id: id.to_string(),
|
transfer_id: begin.transfer_id.clone(),
|
||||||
}),
|
}),
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(10),
|
||||||
)
|
)?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ipc_timeout(timeout_ms: u32) -> Duration {
|
fn ipc_timeout(timeout_ms: u32) -> Duration {
|
||||||
|
|||||||
@@ -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,342 @@
|
|||||||
|
//! Real Windows CLI cancellation, including caller-owned transfer cleanup.
|
||||||
|
#![cfg(windows)]
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use bsk::daemon::{file_transfer::TransferRegistry, info::DaemonInfo};
|
||||||
|
use bsk_protocol::tools::{TransferBeginParams, TransferIdParams};
|
||||||
|
use bsk_protocol::{ErrorCode, Frame, Method, ResponseBody, ResponseFrame, RpcError};
|
||||||
|
use serde_json::json;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::windows::named_pipe::ServerOptions;
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tokio::sync::{Notify, watch};
|
||||||
|
|
||||||
|
fn response<T: serde::Serialize>(result: Result<T, RpcError>) -> ResponseBody {
|
||||||
|
match result {
|
||||||
|
Ok(value) => ResponseBody::Ok(serde_json::to_value(value).unwrap()),
|
||||||
|
Err(err) => ResponseBody::Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stdin_close_cancels_business_but_releases_resources_with_a_bounded_budget() {
|
||||||
|
// Resolve the production registry's root before starting any runtime threads.
|
||||||
|
let registry_home = tempfile::tempdir().unwrap();
|
||||||
|
let original_home = std::env::var_os("BSK_HOME");
|
||||||
|
unsafe {
|
||||||
|
std::env::set_var("BSK_HOME", registry_home.path());
|
||||||
|
}
|
||||||
|
let transfers = Arc::new(TransferRegistry::new().unwrap());
|
||||||
|
unsafe {
|
||||||
|
match original_home {
|
||||||
|
Some(value) => std::env::set_var("BSK_HOME", value),
|
||||||
|
None => std::env::remove_var("BSK_HOME"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transfers.initialize().unwrap();
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(exercise(
|
||||||
|
transfers,
|
||||||
|
®istry_home.path().join("run/transfers"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exercise(transfers: Arc<TransferRegistry>, staging_root: &Path) {
|
||||||
|
for mode in [
|
||||||
|
"wait",
|
||||||
|
"upload",
|
||||||
|
"upload-multi",
|
||||||
|
"upload-before-dispatch",
|
||||||
|
"download",
|
||||||
|
"upload-multi-stall",
|
||||||
|
"ordinary",
|
||||||
|
] {
|
||||||
|
let home = tempfile::tempdir().unwrap();
|
||||||
|
let pipe_name = format!(r"\\.\pipe\bsk-parent-cancel-{}", uuid::Uuid::new_v4());
|
||||||
|
let info = DaemonInfo::now(std::process::id(), (&pipe_name).into(), 0, "0.2.0");
|
||||||
|
std::fs::write(
|
||||||
|
home.path().join("daemon.json"),
|
||||||
|
serde_json::to_vec(&info).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut pipe = ServerOptions::new()
|
||||||
|
.first_pipe_instance(true)
|
||||||
|
.create(&pipe_name)
|
||||||
|
.unwrap();
|
||||||
|
let registered = Arc::new(Notify::new());
|
||||||
|
let pending = Arc::new(Mutex::new(None::<String>));
|
||||||
|
let ids = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
|
let released = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||||
|
let received = Arc::new(Mutex::new(Vec::<Method>::new()));
|
||||||
|
let download_id = if mode == "download" {
|
||||||
|
let staging = transfers.begin_download("fixture").unwrap();
|
||||||
|
let browser_dir = home.path().join("BrowserSkill").join(&staging.transfer_id);
|
||||||
|
std::fs::create_dir_all(&browser_dir).unwrap();
|
||||||
|
let file = browser_dir.join("download.txt");
|
||||||
|
std::fs::write(&file, b"download fixture").unwrap();
|
||||||
|
transfers
|
||||||
|
.import_download(&staging.transfer_id, &file)
|
||||||
|
.unwrap();
|
||||||
|
ids.lock().unwrap().push(staging.transfer_id.clone());
|
||||||
|
Some(staging.transfer_id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let (cancel, cancelled) = watch::channel(false);
|
||||||
|
let ready = Arc::clone(®istered);
|
||||||
|
let name = pipe_name.clone();
|
||||||
|
let registry = Arc::clone(&transfers);
|
||||||
|
let server_ids = Arc::clone(&ids);
|
||||||
|
let server_released = Arc::clone(&released);
|
||||||
|
let server_received = Arc::clone(&received);
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let mut connections = tokio::task::JoinSet::new();
|
||||||
|
loop {
|
||||||
|
pipe.connect().await.unwrap();
|
||||||
|
let next = ServerOptions::new().create(&name).unwrap();
|
||||||
|
let connection = std::mem::replace(&mut pipe, next);
|
||||||
|
let ready = Arc::clone(&ready);
|
||||||
|
let pending = Arc::clone(&pending);
|
||||||
|
let cancel = cancel.clone();
|
||||||
|
let mut cancelled = cancelled.clone();
|
||||||
|
let name = name.clone();
|
||||||
|
let registry = Arc::clone(®istry);
|
||||||
|
let ids = Arc::clone(&server_ids);
|
||||||
|
let released = Arc::clone(&server_released);
|
||||||
|
let received = Arc::clone(&server_received);
|
||||||
|
let download_id = download_id.clone();
|
||||||
|
connections.spawn(async move {
|
||||||
|
let (read, mut write) = tokio::io::split(connection);
|
||||||
|
let mut reader = BufReader::new(read);
|
||||||
|
let mut line = String::new();
|
||||||
|
while reader.read_line(&mut line).await.unwrap_or(0) > 0 {
|
||||||
|
let Frame::Request(req) = serde_json::from_str::<Frame>(&line).unwrap()
|
||||||
|
else {
|
||||||
|
panic!("expected request");
|
||||||
|
};
|
||||||
|
line.clear();
|
||||||
|
received.lock().unwrap().push(req.method.clone());
|
||||||
|
let params = req.params.clone().unwrap_or_default();
|
||||||
|
let body = match req.method {
|
||||||
|
Method::SystemStatus => ResponseBody::Ok(json!({
|
||||||
|
"daemon_version": "0.2.0", "protocol_version": "1.1",
|
||||||
|
"pid": std::process::id(), "uptime_secs": 0,
|
||||||
|
"ws_port": 0, "sock_path": name,
|
||||||
|
"browsers": [], "sessions": [], "version_skew_browsers": []
|
||||||
|
})),
|
||||||
|
Method::TransferBegin => {
|
||||||
|
let begin = registry
|
||||||
|
.begin_upload(serde_json::from_value(params).unwrap())
|
||||||
|
.unwrap();
|
||||||
|
ids.lock().unwrap().push(begin.transfer_id.clone());
|
||||||
|
response(Ok(begin))
|
||||||
|
}
|
||||||
|
Method::ToolDownload => ResponseBody::Ok(json!({
|
||||||
|
"tab_id": 1, "suggested_filename": "download.txt",
|
||||||
|
"byte_size": 16, "transfer_id": download_id,
|
||||||
|
})),
|
||||||
|
Method::ToolWaitMs if mode == "ordinary" => {
|
||||||
|
ResponseBody::Ok(json!({"waited_ms": 1}))
|
||||||
|
}
|
||||||
|
Method::TransferChunk
|
||||||
|
if mode == "upload-before-dispatch"
|
||||||
|
|| (mode.contains("multi")
|
||||||
|
&& ids.lock().unwrap().len() == 1) =>
|
||||||
|
{
|
||||||
|
response(
|
||||||
|
registry.write_chunk(serde_json::from_value(params).unwrap()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Method::TransferFinish if mode != "upload-before-dispatch" => response(
|
||||||
|
registry.finish_upload(serde_json::from_value(params).unwrap()),
|
||||||
|
),
|
||||||
|
Method::ToolWaitMs
|
||||||
|
| Method::TransferChunk
|
||||||
|
| Method::TransferRead
|
||||||
|
| Method::TransferFinish => {
|
||||||
|
*pending.lock().unwrap() = Some(req.id.clone());
|
||||||
|
ready.notify_one();
|
||||||
|
cancelled.wait_for(|value| *value).await.unwrap();
|
||||||
|
if mode == "upload-before-dispatch" {
|
||||||
|
// Finish won the race with cancellation, but tool.upload must
|
||||||
|
// not be dispatched and the CLI still owns the staging.
|
||||||
|
response(
|
||||||
|
registry
|
||||||
|
.finish_upload(serde_json::from_value(params).unwrap()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ResponseBody::Err(RpcError {
|
||||||
|
code: ErrorCode::Cancelled,
|
||||||
|
message: "parent cancellation reached daemon".into(),
|
||||||
|
data: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Method::Cancel => {
|
||||||
|
let target = params["rpc_id"].as_str().unwrap();
|
||||||
|
assert_eq!(pending.lock().unwrap().as_deref(), Some(target));
|
||||||
|
cancel.send(true).unwrap();
|
||||||
|
ResponseBody::Ok(json!({"cancelled": true}))
|
||||||
|
}
|
||||||
|
Method::TransferRelease => {
|
||||||
|
let params: TransferIdParams =
|
||||||
|
serde_json::from_value(params).unwrap();
|
||||||
|
released.lock().unwrap().push(params.transfer_id.clone());
|
||||||
|
if mode.contains("stall") {
|
||||||
|
std::future::pending::<()>().await;
|
||||||
|
}
|
||||||
|
response(Ok(registry.release(params)))
|
||||||
|
}
|
||||||
|
other => panic!("unexpected request after cancellation: {other:?}"),
|
||||||
|
};
|
||||||
|
let response = Frame::Response(ResponseFrame { id: req.id, body });
|
||||||
|
let mut bytes = serde_json::to_vec(&response).unwrap();
|
||||||
|
bytes.push(b'\n');
|
||||||
|
if write.write_all(&bytes).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_bsk"));
|
||||||
|
cmd.env("BSK_HOME", home.path())
|
||||||
|
.env("BSK_AUTO_UPDATE", "off")
|
||||||
|
.env_remove("BSK_CANCEL_ON_STDIN_CLOSE")
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.creation_flags(0x0800_0000)
|
||||||
|
.kill_on_drop(true);
|
||||||
|
if mode.starts_with("upload") {
|
||||||
|
let source = home.path().join("upload.txt");
|
||||||
|
// Declare 300 MiB without allocating or transferring that much data.
|
||||||
|
std::fs::File::create(&source)
|
||||||
|
.unwrap()
|
||||||
|
.set_len(if mode == "upload-before-dispatch" {
|
||||||
|
16
|
||||||
|
} else {
|
||||||
|
300 * 1024 * 1024
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
cmd.args(["upload", "--session", "fixture", "--selector", "input"]);
|
||||||
|
if mode.contains("multi") {
|
||||||
|
let first = home.path().join("first.txt");
|
||||||
|
std::fs::write(&first, b"first file").unwrap();
|
||||||
|
cmd.arg("--file").arg(first);
|
||||||
|
}
|
||||||
|
cmd.arg("--file").arg(source);
|
||||||
|
} else if mode == "download" {
|
||||||
|
let out = home.path().join("out.txt");
|
||||||
|
std::fs::write(&out, b"existing output").unwrap();
|
||||||
|
cmd.args([
|
||||||
|
"download",
|
||||||
|
"--session",
|
||||||
|
"fixture",
|
||||||
|
"--selector",
|
||||||
|
"a",
|
||||||
|
"--overwrite",
|
||||||
|
"--out",
|
||||||
|
])
|
||||||
|
.arg(out);
|
||||||
|
} else {
|
||||||
|
cmd.args(["wait-ms", if mode == "ordinary" { "1ms" } else { "60s" }]);
|
||||||
|
}
|
||||||
|
cmd.arg("--json");
|
||||||
|
if mode == "ordinary" {
|
||||||
|
cmd.stdin(std::process::Stdio::null());
|
||||||
|
} else {
|
||||||
|
cmd.env("BSK_CANCEL_ON_STDIN_CLOSE", "1")
|
||||||
|
.stdin(std::process::Stdio::piped());
|
||||||
|
}
|
||||||
|
let mut child = cmd.spawn().unwrap();
|
||||||
|
if mode != "ordinary" {
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), registered.notified())
|
||||||
|
.await
|
||||||
|
.expect("CLI registered business RPC");
|
||||||
|
child
|
||||||
|
.stdin
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.write_all(b"still connected")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
drop(child.stdin.take());
|
||||||
|
}
|
||||||
|
let start = Instant::now();
|
||||||
|
let output = tokio::time::timeout(Duration::from_secs(8), child.wait_with_output())
|
||||||
|
.await
|
||||||
|
.expect("CLI must settle promptly, including cleanup")
|
||||||
|
.unwrap();
|
||||||
|
server.abort();
|
||||||
|
let _ = server.await;
|
||||||
|
assert_eq!(
|
||||||
|
output.status.code(),
|
||||||
|
Some(if mode == "ordinary" { 0 } else { 2 }),
|
||||||
|
"{mode}: stdout={} stderr={}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
if mode != "ordinary" {
|
||||||
|
let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||||||
|
assert_eq!(body["code"], "cancelled");
|
||||||
|
if mode != "upload-before-dispatch" {
|
||||||
|
assert_eq!(body["message"], "parent cancellation reached daemon");
|
||||||
|
}
|
||||||
|
let received = received.lock().unwrap();
|
||||||
|
let cancel = received.iter().position(|m| *m == Method::Cancel).unwrap();
|
||||||
|
assert!(
|
||||||
|
received[cancel + 1..]
|
||||||
|
.iter()
|
||||||
|
.all(|m| *m == Method::TransferRelease),
|
||||||
|
"only cleanup may follow cancellation: {received:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let ids = ids.lock().unwrap();
|
||||||
|
if mode.contains("stall") {
|
||||||
|
assert_eq!(released.lock().unwrap().len(), 1);
|
||||||
|
assert!(start.elapsed() >= Duration::from_secs(4));
|
||||||
|
// The CLI cannot force an unresponsive daemon to clean up.
|
||||||
|
transfers.release_session("fixture");
|
||||||
|
} else {
|
||||||
|
assert_eq!(
|
||||||
|
*released.lock().unwrap(),
|
||||||
|
*ids,
|
||||||
|
"{mode}: every allocation must be released"
|
||||||
|
);
|
||||||
|
for id in ids.iter() {
|
||||||
|
assert!(!staging_root.join(id).exists(), "staging leaked: {id}");
|
||||||
|
}
|
||||||
|
if mode.starts_with("upload") {
|
||||||
|
let retry = transfers
|
||||||
|
.begin_upload(TransferBeginParams {
|
||||||
|
session_id: "fixture".into(),
|
||||||
|
name: "retry.txt".into(),
|
||||||
|
byte_size: 300 * 1024 * 1024,
|
||||||
|
})
|
||||||
|
.expect("same session must have room for another 300 MiB upload");
|
||||||
|
transfers.release(TransferIdParams {
|
||||||
|
transfer_id: retry.transfer_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "download" {
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read(home.path().join("out.txt")).unwrap(),
|
||||||
|
b"existing output"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!std::fs::read_dir(home.path())
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.any(|entry| entry.file_name().to_string_lossy().ends_with(".part"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
+41
-15
@@ -21,6 +21,7 @@ $ErrorActionPreference = "Stop"
|
|||||||
|
|
||||||
$Repo = if ($env:BSK_REPO) { $env:BSK_REPO } else { "Tencent/BrowserSkill" }
|
$Repo = if ($env:BSK_REPO) { $env:BSK_REPO } else { "Tencent/BrowserSkill" }
|
||||||
$InstallDir = if ($env:BSK_INSTALL_DIR) { $env:BSK_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
|
$InstallDir = if ($env:BSK_INSTALL_DIR) { $env:BSK_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
|
||||||
|
$InstallDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($InstallDir)
|
||||||
$GitHub = "https://github.com/${Repo}"
|
$GitHub = "https://github.com/${Repo}"
|
||||||
|
|
||||||
function Write-Log {
|
function Write-Log {
|
||||||
@@ -64,7 +65,7 @@ function Get-PlatformTriple {
|
|||||||
function Add-ToUserPath {
|
function Add-ToUserPath {
|
||||||
param([string]$Dir)
|
param([string]$Dir)
|
||||||
|
|
||||||
$currentUserPath = [Environment]::GetEnvironmentVariable("PATH", "User") -split ";" | Where-Object { $_ }
|
$currentUserPath = @([Environment]::GetEnvironmentVariable("PATH", "User") -split ";" | Where-Object { $_ })
|
||||||
|
|
||||||
if ($currentUserPath -contains $Dir) {
|
if ($currentUserPath -contains $Dir) {
|
||||||
Write-Log "$Dir is already in your user PATH"
|
Write-Log "$Dir is already in your user PATH"
|
||||||
@@ -90,30 +91,59 @@ function Add-ToSessionPath {
|
|||||||
# ── Git Bash (bash environment) PATH helper ──────────────────────────────────
|
# ── Git Bash (bash environment) PATH helper ──────────────────────────────────
|
||||||
|
|
||||||
function Add-ToBashProfile {
|
function Add-ToBashProfile {
|
||||||
param([string]$Dir)
|
param([string]$Dir, [string]$BashRc = (Join-Path $HOME ".bashrc"))
|
||||||
|
|
||||||
# Convert Windows path (e.g. C:\Users\foo\.local\bin) to Git-Bash Unix-style (/c/Users/foo/.local/bin)
|
# Convert Windows path (e.g. C:\Users\foo\.local\bin) to Git-Bash Unix-style (/c/Users/foo/.local/bin)
|
||||||
$unixPath = $Dir -replace '\\', '/'
|
$unixPath = $Dir -replace '\\', '/'
|
||||||
if ($unixPath -match '^([A-Z]):(.*)$') {
|
if ($unixPath -match '^([A-Z]):(.*)$') {
|
||||||
$unixPath = '/' + $matches[1].ToLower() + $matches[2]
|
$unixPath = '/' + $matches[1].ToLower() + $matches[2]
|
||||||
}
|
}
|
||||||
$bashRc = Join-Path $HOME ".bashrc"
|
# Single-quote the literal directory; only the existing PATH is expanded.
|
||||||
$exportLine = "export PATH=""${unixPath}:`$PATH"" # bsk CLI"
|
$shellQuote = "'" + [char]34 + "'" + [char]34 + "'"
|
||||||
|
$quotedPath = "'" + $unixPath.Replace("'", $shellQuote) + "'"
|
||||||
|
$exportLine = "export PATH=${quotedPath}:`"`$PATH`" # bsk CLI"
|
||||||
|
|
||||||
if (Test-Path $bashRc) {
|
if (Test-Path -LiteralPath $BashRc) {
|
||||||
$content = Get-Content $bashRc -Raw -ErrorAction SilentlyContinue
|
$content = [System.IO.File]::ReadAllText($BashRc)
|
||||||
if ($content -match [regex]::Escape($unixPath)) {
|
if ($content.Contains($exportLine)) {
|
||||||
Write-Log "$unixPath is already in ~/.bashrc"
|
Write-Log "$unixPath is already in ~/.bashrc"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Add-Content $bashRc "`n$exportLine" -Encoding ASCII
|
# Explicit BOM-less UTF-8 also works in Windows PowerShell 5.1.
|
||||||
|
[System.IO.File]::AppendAllText($BashRc, "`n$exportLine`n", (New-Object System.Text.UTF8Encoding($false)))
|
||||||
Write-Log "added ${unixPath} to ~/.bashrc"
|
Write-Log "added ${unixPath} to ~/.bashrc"
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Stage on the destination volume before stopping the daemon. Never truncate
|
||||||
|
# the installed executable: failed replacement must leave it usable.
|
||||||
|
function Install-Binary {
|
||||||
|
param([string]$Source, [string]$Target)
|
||||||
|
|
||||||
|
$staged = "$Target.install-$([Guid]::NewGuid().ToString('N'))"
|
||||||
|
try {
|
||||||
|
[System.IO.File]::Copy($Source, $staged)
|
||||||
|
if ([System.IO.File]::Exists($Target)) {
|
||||||
|
# Use the downloaded CLI: older versions have broken Windows
|
||||||
|
# liveness checks. Stop verifies daemon identity before terminating it.
|
||||||
|
& $Source daemon stop
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "could not stop bsk daemon; existing installation was not replaced" }
|
||||||
|
# PowerShell 5.1 converts $null to an empty path for string parameters.
|
||||||
|
[System.IO.File]::Replace($staged, $Target, [NullString]::Value)
|
||||||
|
Write-Log "daemon will restart automatically on the next browser command"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
[System.IO.File]::Move($staged, $Target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ([System.IO.File]::Exists($staged)) { [System.IO.File]::Delete($staged) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Main {
|
function Main {
|
||||||
$platform = Get-PlatformTriple
|
$platform = Get-PlatformTriple
|
||||||
|
|
||||||
@@ -184,7 +214,7 @@ function Main {
|
|||||||
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
Copy-Item -Path (Join-Path $tempDir "bsk.exe") -Destination (Join-Path $InstallDir "bsk.exe") -Force
|
Install-Binary -Source (Join-Path $tempDir "bsk.exe") -Target (Join-Path $InstallDir "bsk.exe")
|
||||||
|
|
||||||
Write-Log "installed bsk to $InstallDir\bsk.exe"
|
Write-Log "installed bsk to $InstallDir\bsk.exe"
|
||||||
|
|
||||||
@@ -199,12 +229,8 @@ function Main {
|
|||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
$bskPath = Join-Path $InstallDir "bsk.exe"
|
$bskPath = Join-Path $InstallDir "bsk.exe"
|
||||||
if (Get-Command bsk -ErrorAction SilentlyContinue) {
|
& $bskPath --version
|
||||||
& bsk --version
|
if ($LASTEXITCODE -ne 0) { throw "installed bsk failed verification" }
|
||||||
}
|
|
||||||
else {
|
|
||||||
Write-Log "verify install: & ""$bskPath"" --version"
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Log "done"
|
Write-Log "done"
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
@@ -47,7 +47,6 @@
|
|||||||
"client": {
|
"client": {
|
||||||
"platform": "web",
|
"platform": "web",
|
||||||
"inject": [
|
"inject": [
|
||||||
"@deepseek-ai/dsh-client-runtime",
|
|
||||||
"@deepseek-ai/dsh-client-ui-tool",
|
"@deepseek-ai/dsh-client-ui-tool",
|
||||||
"@deepseek-ai/dsh-client-ui-layout"
|
"@deepseek-ai/dsh-client-ui-layout"
|
||||||
],
|
],
|
||||||
@@ -69,7 +68,6 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@deepseek-ai/cordis": "^4.0.1",
|
"@deepseek-ai/cordis": "^4.0.1",
|
||||||
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
|
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
|
||||||
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
||||||
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.0-rc.6",
|
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.0-rc.6",
|
||||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
||||||
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
|
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
|
||||||
@@ -82,9 +80,6 @@
|
|||||||
"@deepseek-ai/dsh-attachment": {
|
"@deepseek-ai/dsh-attachment": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"@deepseek-ai/dsh-client-runtime": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@deepseek-ai/dsh-client-ui-attachment": {
|
"@deepseek-ai/dsh-client-ui-attachment": {
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
|
||||||
|
// Development-only types for the injected services; newer hosts no longer ship this package.
|
||||||
import type { ClientContext, ISessions, SessionId } from "@deepseek-ai/dsh-client-runtime/client";
|
import type { ClientContext, ISessions, SessionId } from "@deepseek-ai/dsh-client-runtime/client";
|
||||||
// Type-only: pulls the 'shell.overlay' SlotMap merge into scope.
|
// Type-only: pulls the 'shell.overlay' SlotMap merge into scope.
|
||||||
import type {} from "@deepseek-ai/dsh-client-ui-layout/client";
|
import type {} from "@deepseek-ai/dsh-client-ui-layout/client";
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* map the CLI's JSON error envelope onto a thrown `BskError`.
|
* map the CLI's JSON error envelope onto a thrown `BskError`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type ChildProcess, spawn } from "node:child_process";
|
import { type ChildProcess, type SpawnOptionsWithoutStdio, spawn } from "node:child_process";
|
||||||
|
|
||||||
/** Shape of the JSON error envelope `bsk --json` prints on failure. */
|
/** Shape of the JSON error envelope `bsk --json` prints on failure. */
|
||||||
export interface BskErrorBody {
|
export interface BskErrorBody {
|
||||||
@@ -53,7 +53,11 @@ export interface BskRunOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal spawn signature so tests can substitute a fake child process. */
|
/** Minimal spawn signature so tests can substitute a fake child process. */
|
||||||
export type SpawnImpl = (command: string, args: string[]) => ChildProcess;
|
export type SpawnImpl = (
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
options?: SpawnOptionsWithoutStdio,
|
||||||
|
) => ChildProcess;
|
||||||
|
|
||||||
export interface BskRunner {
|
export interface BskRunner {
|
||||||
/** Run `bsk <args...> --json` and collect its output. */
|
/** Run `bsk <args...> --json` and collect its output. */
|
||||||
@@ -64,29 +68,66 @@ export interface BskRunner {
|
|||||||
killFor(tag: string): number;
|
killFor(tag: string): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business RPCs translate SIGINT into the daemon's cancel(rpc_id) protocol.
|
// Business RPCs translate Ctrl-C / opt-in stdin EOF into cancel(rpc_id).
|
||||||
// Give that bounded reconciliation path time to settle before the hard kill.
|
// Allow reconciliation before hard-killing an old or unresponsive CLI.
|
||||||
const KILL_GRACE_MS = 3000;
|
const KILL_GRACE_MS = 3000;
|
||||||
|
// Windows IPC may spend 5s connecting, 2s cancelling, 2s settling,
|
||||||
|
// and up to 5s releasing the entire batch of caller-owned transfers.
|
||||||
|
const WINDOWS_KILL_GRACE_MS = 15_000;
|
||||||
const SESSION_BUSY_RETRY_DELAY_MS = 100;
|
const SESSION_BUSY_RETRY_DELAY_MS = 100;
|
||||||
|
|
||||||
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
|
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
|
||||||
const live = new Map<ChildProcess, string | undefined>();
|
const live = new Map<ChildProcess, string | undefined>();
|
||||||
|
const windows = process.platform === "win32";
|
||||||
|
const cancelling = new Set<ChildProcess>();
|
||||||
|
|
||||||
function killChild(child: ChildProcess): void {
|
function killChild(child: ChildProcess): void {
|
||||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
if (child.exitCode !== null || child.signalCode !== null || cancelling.has(child)) return;
|
||||||
child.kill("SIGINT");
|
cancelling.add(child);
|
||||||
const force = setTimeout(() => {
|
// Node kills Windows children outright for SIGINT. EOF asks the CLI to
|
||||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
// send its existing cancel RPC and wait for browser reconciliation.
|
||||||
}, KILL_GRACE_MS);
|
if (windows && child.stdin) child.stdin.end();
|
||||||
|
else child.kill("SIGINT");
|
||||||
|
const force = setTimeout(
|
||||||
|
() => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||||
|
},
|
||||||
|
windows ? WINDOWS_KILL_GRACE_MS : KILL_GRACE_MS,
|
||||||
|
);
|
||||||
force.unref();
|
force.unref();
|
||||||
|
child.once("close", () => {
|
||||||
|
clearTimeout(force);
|
||||||
|
cancelling.delete(child);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
run(args, options = {}) {
|
run(args, options = {}) {
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
return Promise.resolve({
|
||||||
|
code: null,
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
timedOut: false,
|
||||||
|
aborted: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
return new Promise<BskRunResult>((resolve, reject) => {
|
return new Promise<BskRunResult>((resolve, reject) => {
|
||||||
let child: ChildProcess;
|
let child: ChildProcess;
|
||||||
try {
|
try {
|
||||||
child = spawnImpl(bskPath, [...args, "--json"]);
|
child = spawnImpl(
|
||||||
|
bskPath,
|
||||||
|
[...args, "--json"],
|
||||||
|
windows
|
||||||
|
? {
|
||||||
|
windowsHide: true,
|
||||||
|
env: { ...process.env, BSK_CANCEL_ON_STDIN_CLOSE: "1" },
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
// A child exiting while cancellation closes stdin may report EPIPE.
|
||||||
|
// Its close/error event remains the authority for the run result.
|
||||||
|
child.stdin?.on("error", () => {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { describe, expect, it } from "vitest";
|
import { PassThrough } from "node:stream";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
type BskRunResult,
|
type BskRunResult,
|
||||||
createBskRunner,
|
createBskRunner,
|
||||||
@@ -92,6 +93,77 @@ describe("createBskRunner", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Windows parent cancellation", () => {
|
||||||
|
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
||||||
|
beforeEach(() => Object.defineProperty(process, "platform", { value: "win32" }));
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(process, "platform", originalPlatform);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opts in to stdin cancellation and waits for CLI reconciliation", async () => {
|
||||||
|
const child = Object.assign(new FakeChild(), { stdin: new PassThrough() });
|
||||||
|
const spawn = vi.fn(() => child as unknown as ChildProcess);
|
||||||
|
const runner = createBskRunner("bsk", spawn);
|
||||||
|
const abort = new AbortController();
|
||||||
|
const result = runner.run(["snapshot"], { signal: abort.signal });
|
||||||
|
expect(spawn.mock.calls[0]).toEqual([
|
||||||
|
"bsk",
|
||||||
|
["snapshot", "--json"],
|
||||||
|
{
|
||||||
|
windowsHide: true,
|
||||||
|
env: { ...process.env, BSK_CANCEL_ON_STDIN_CLOSE: "1" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
abort.abort();
|
||||||
|
expect(child.stdin.writableEnded).toBe(true);
|
||||||
|
expect(child.killedWith).toEqual([]);
|
||||||
|
// Closing an already-exited child's pipe must not crash the host.
|
||||||
|
child.stdin.emit("error", Object.assign(new Error("closed"), { code: "EPIPE" }));
|
||||||
|
child.finish(2, '{"code":"cancelled"}');
|
||||||
|
expect(await result).toMatchObject({ code: 2, aborted: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds cancellation of an old or unresponsive CLI", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const child = Object.assign(new FakeChild(), { stdin: new PassThrough() });
|
||||||
|
const runner = createBskRunner("bsk", fakeSpawn([child]));
|
||||||
|
const result = runner.run(["snapshot"], { timeoutMs: 5 });
|
||||||
|
await vi.advanceTimersByTimeAsync(5);
|
||||||
|
expect(child.stdin.writableEnded).toBe(true);
|
||||||
|
expect(child.killedWith).toEqual([]);
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000);
|
||||||
|
expect(child.killedWith).toEqual([]);
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
expect(child.killedWith).toEqual(["SIGKILL"]);
|
||||||
|
expect(await result).toMatchObject({ timedOut: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels only the requested tag and clears the fallback after exit", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const a = Object.assign(new FakeChild(), { stdin: new PassThrough() });
|
||||||
|
const b = Object.assign(new FakeChild(), { stdin: new PassThrough() });
|
||||||
|
const runner = createBskRunner("bsk", fakeSpawn([a, b]));
|
||||||
|
const first = runner.run(["snapshot"], { tag: "a" });
|
||||||
|
const second = runner.run(["snapshot"], { tag: "b" });
|
||||||
|
expect(runner.killFor("a")).toBe(1);
|
||||||
|
expect(a.stdin.writableEnded).toBe(true);
|
||||||
|
expect(b.stdin.writableEnded).toBe(false);
|
||||||
|
a.finish(2);
|
||||||
|
b.finish(0);
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start work for an already-aborted call", async () => {
|
||||||
|
const spawn = vi.fn();
|
||||||
|
const runner = createBskRunner("bsk", spawn);
|
||||||
|
const signal = AbortSignal.abort();
|
||||||
|
expect(await runner.run(["snapshot"], { signal })).toMatchObject({ aborted: true });
|
||||||
|
expect(spawn).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("parseBskJson", () => {
|
describe("parseBskJson", () => {
|
||||||
const base: BskRunResult = { code: 0, stdout: "", stderr: "", timedOut: false, aborted: false };
|
const base: BskRunResult = { code: 0, stdout: "", stderr: "", timedOut: false, aborted: false };
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ const CLIENT_EXTERNALS: readonly string[] = [
|
|||||||
"@deepseek-ai/dsh-client-ui-primitives",
|
"@deepseek-ai/dsh-client-ui-primitives",
|
||||||
"@deepseek-ai/dsh-client-ui-attachment",
|
"@deepseek-ai/dsh-client-ui-attachment",
|
||||||
"@deepseek-ai/dsh-client-schema-form",
|
"@deepseek-ai/dsh-client-schema-form",
|
||||||
"@deepseek-ai/dsh-client-runtime/client",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Wire/type layers with no shared runtime identity: safe to inline. */
|
/** Wire/type layers with no shared runtime identity: safe to inline. */
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
#Requires -Version 5.1
|
||||||
|
param([string]$BskPath = (Join-Path $PSScriptRoot "../target/debug/bsk.exe"))
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
# Load definitions, never Main: no downloads or changes to the real user PATH.
|
||||||
|
$installer = Join-Path $PSScriptRoot "../install.ps1"
|
||||||
|
$tokens = $null
|
||||||
|
$errors = $null
|
||||||
|
$ast = [System.Management.Automation.Language.Parser]::ParseFile($installer, [ref]$tokens, [ref]$errors)
|
||||||
|
if ($errors.Count) { throw ($errors | Out-String) }
|
||||||
|
foreach ($statement in $ast.EndBlock.Statements) {
|
||||||
|
if ($statement -is [System.Management.Automation.Language.FunctionDefinitionAst]) {
|
||||||
|
$definition = $statement.Extent.Text
|
||||||
|
if ($statement.Name -eq "Add-ToUserPath") {
|
||||||
|
$definition = $definition.Replace('[Environment]::GetEnvironmentVariable("PATH", "User")', '$script:UserPath')
|
||||||
|
$definition = $definition.Replace('[Environment]::SetEnvironmentVariable("PATH", $newUserPath, "User")', '$script:UserPath = $newUserPath')
|
||||||
|
}
|
||||||
|
Invoke-Expression $definition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function Assert-Equal($Actual, $Expected) {
|
||||||
|
if ($Actual -cne $Expected) { throw "expected [$Expected], got [$Actual]" }
|
||||||
|
}
|
||||||
|
function Assert-Fails([scriptblock]$Action) {
|
||||||
|
$failed = $false
|
||||||
|
try { & $Action } catch { $failed = $true }
|
||||||
|
if (-not $failed) { throw "expected failure" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = 'C:\Users\Alice\.local\bin'
|
||||||
|
foreach ($existing in @('', 'C:\WindowsApps', 'C:\A;C:\B')) {
|
||||||
|
$script:UserPath = $existing
|
||||||
|
Add-ToUserPath $dir
|
||||||
|
$expected = if ($existing) { "$existing;$dir" } else { $dir }
|
||||||
|
Assert-Equal $script:UserPath $expected
|
||||||
|
Add-ToUserPath $dir
|
||||||
|
Assert-Equal $script:UserPath $expected
|
||||||
|
}
|
||||||
|
|
||||||
|
$root = Join-Path ([IO.Path]::GetTempPath()) ("bsk-install-test-" + [Guid]::NewGuid().ToString('N'))
|
||||||
|
[IO.Directory]::CreateDirectory($root) | Out-Null
|
||||||
|
$oldBskHome = $env:BSK_HOME
|
||||||
|
$oldAutoUpdate = $env:BSK_AUTO_UPDATE
|
||||||
|
$daemon = $null
|
||||||
|
try {
|
||||||
|
$utf8 = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
$bashRc = Join-Path $root ".bashrc"
|
||||||
|
$existing = "# existing 中文 configuration" + [char]10
|
||||||
|
[IO.File]::WriteAllText($bashRc, $existing, $utf8)
|
||||||
|
$special = 'C:\Users\张三 space $HOME $(echo unsafe) ! & [x] O''Brien\.local\bin'
|
||||||
|
Add-ToBashProfile $special $bashRc
|
||||||
|
$first = [IO.File]::ReadAllText($bashRc)
|
||||||
|
if (-not $first.StartsWith($existing)) { throw "existing bashrc content changed" }
|
||||||
|
if (-not $first.Contains('张三')) { throw "Unicode path was lost" }
|
||||||
|
Add-ToBashProfile $special $bashRc
|
||||||
|
Assert-Equal ([IO.File]::ReadAllText($bashRc)) $first
|
||||||
|
$bytes = [IO.File]::ReadAllBytes($bashRc)
|
||||||
|
if ($bytes[0] -eq 0xEF) { throw "unexpected UTF-8 BOM" }
|
||||||
|
|
||||||
|
$bash = [IO.Path]::GetFullPath((Join-Path (Split-Path (Get-Command git).Source -Parent) "../bin/bash.exe"))
|
||||||
|
if (-not (Test-Path -LiteralPath $bash)) { throw "Git Bash is required for quoting regression" }
|
||||||
|
$env:BSK_TEST_RC = $bashRc
|
||||||
|
$probe = Join-Path $root 'probe.sh'
|
||||||
|
[IO.File]::WriteAllText($probe, 'source "$BSK_TEST_RC"; printf "%s\n" "${PATH%%:*}"', $utf8)
|
||||||
|
$actual = & $bash --noprofile --norc $probe
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "Git Bash failed" }
|
||||||
|
Assert-Equal $actual ('/c' + $special.Substring(2).Replace('\', '/'))
|
||||||
|
|
||||||
|
$env:BSK_HOME = Join-Path $root "home"
|
||||||
|
$env:BSK_AUTO_UPDATE = "off"
|
||||||
|
[IO.Directory]::CreateDirectory($env:BSK_HOME) | Out-Null
|
||||||
|
$sourceDir = Join-Path $root "download"
|
||||||
|
[IO.Directory]::CreateDirectory($sourceDir) | Out-Null
|
||||||
|
$source = Join-Path $sourceDir "bsk.exe"
|
||||||
|
[IO.File]::Copy((Resolve-Path -LiteralPath $BskPath).Path, $source)
|
||||||
|
$targetDir = Join-Path $root "中文 space [x] & install"
|
||||||
|
[IO.Directory]::CreateDirectory($targetDir) | Out-Null
|
||||||
|
$target = Join-Path $targetDir "bsk.exe"
|
||||||
|
Install-Binary $source $target
|
||||||
|
Assert-Equal (Get-FileHash -LiteralPath $target).Hash (Get-FileHash -LiteralPath $source).Hash
|
||||||
|
|
||||||
|
$daemon = Start-Process -FilePath $target -ArgumentList @('daemon', 'start', '--foreground', '--port', '0') -WindowStyle Hidden -PassThru
|
||||||
|
$infoPath = Join-Path $env:BSK_HOME "daemon.json"
|
||||||
|
$deadline = [DateTime]::UtcNow.AddSeconds(15)
|
||||||
|
while (-not [IO.File]::Exists($infoPath)) {
|
||||||
|
if ($daemon.HasExited -or [DateTime]::UtcNow -gt $deadline) { throw "daemon failed to start" }
|
||||||
|
Start-Sleep -Milliseconds 50
|
||||||
|
}
|
||||||
|
# PE overlays distinguish old/new executables without another build.
|
||||||
|
$stream = [IO.File]::OpenWrite($source)
|
||||||
|
try {
|
||||||
|
$stream.Seek(0, [IO.SeekOrigin]::End) | Out-Null
|
||||||
|
$marker = $utf8.GetBytes("installer replacement regression")
|
||||||
|
$stream.Write($marker, 0, $marker.Length)
|
||||||
|
} finally { $stream.Dispose() }
|
||||||
|
Install-Binary $source $target
|
||||||
|
if (-not $daemon.WaitForExit(5000)) { throw "old daemon still running" }
|
||||||
|
Assert-Equal (Get-FileHash -LiteralPath $target).Hash (Get-FileHash -LiteralPath $source).Hash
|
||||||
|
& $target --version
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw "replacement is not executable" }
|
||||||
|
|
||||||
|
# Refuse replacement when daemon identity cannot be verified.
|
||||||
|
[IO.File]::WriteAllText($infoPath, 'invalid daemon metadata')
|
||||||
|
$before = (Get-FileHash -LiteralPath $target).Hash
|
||||||
|
Assert-Fails { Install-Binary $source $target }
|
||||||
|
Assert-Equal (Get-FileHash -LiteralPath $target).Hash $before
|
||||||
|
[IO.File]::Delete($infoPath)
|
||||||
|
|
||||||
|
# A remaining lock must fail without truncation or staging debris.
|
||||||
|
$lock = [IO.File]::Open($target, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None)
|
||||||
|
try { Assert-Fails { Install-Binary $source $target } } finally { $lock.Dispose() }
|
||||||
|
Assert-Equal (Get-FileHash -LiteralPath $target).Hash $before
|
||||||
|
if (@(Get-ChildItem -LiteralPath $targetDir -Filter "*.install-*").Count) { throw "staging files leaked" }
|
||||||
|
Write-Host "Windows installer regressions passed ($($PSVersionTable.PSVersion))"
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host ($_ | Out-String)
|
||||||
|
Write-Host $_.ScriptStackTrace
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if ($daemon -and -not $daemon.HasExited) {
|
||||||
|
Stop-Process -Id $daemon.Id -Force
|
||||||
|
$daemon.WaitForExit(5000) | Out-Null
|
||||||
|
}
|
||||||
|
$env:BSK_HOME = $oldBskHome
|
||||||
|
$env:BSK_AUTO_UPDATE = $oldAutoUpdate
|
||||||
|
Remove-Item Env:BSK_TEST_RC -ErrorAction SilentlyContinue
|
||||||
|
# Resolve and verify before recursive cleanup; only this fixture is removed.
|
||||||
|
$resolvedRoot = [IO.Path]::GetFullPath($root)
|
||||||
|
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
|
||||||
|
if (-not $resolvedRoot.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase) -or
|
||||||
|
-not ([IO.Path]::GetFileName($resolvedRoot)).StartsWith('bsk-install-test-')) { throw "invalid cleanup path" }
|
||||||
|
Remove-Item -LiteralPath $resolvedRoot -Recurse -Force
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user