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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,14 +55,17 @@ jobs:
- name: Run Windows audit and browser interaction policy tests
run: cargo test -p bsk --test audit_ws --test interaction_preferences --locked

- name: Verify background startup refusal inside the runner Job
run: cargo test -p bsk --test windows_daemon_start --locked restrictive_job_refuses_explicit_and_automatic_start_without_a_daemon -- --exact

- name: Run Windows process liveness 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 Windows cancellation process tests
run: cargo test -p bsk --test windows_parent_cancel --locked

- name: Run installer tests (Windows PowerShell 5.1)
shell: powershell
Expand All @@ -72,6 +75,27 @@ jobs:
shell: pwsh
run: ./scripts/install-windows.test.ps1

windows-daemon-lifecycle:
name: Windows independent daemon and update lifecycle
runs-on: windows-latest
timeout-minutes: 20

steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable

- name: Run lifecycle regressions in a verified independent host
shell: powershell
run: ./scripts/test-windows-daemon.ps1

- name: Upload lifecycle test logs
if: always()
uses: actions/upload-artifact@v7
with:
name: windows-daemon-lifecycle
path: target/windows-daemon-validation/
if-no-files-found: warn

frontend:
name: Frontend lint, typecheck, tests, build
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions crates/bsk-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ libc = "0.2"
windows-sys = { version = "0.59", features = [
"Win32_Foundation",
"Win32_System_Threading",
"Win32_System_JobObjects",
"Win32_System_Pipes",
"Win32_Security",
"Win32_Storage_FileSystem",
Expand Down
2 changes: 1 addition & 1 deletion crates/bsk-cli/src/cli/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ pub struct StartArgs {
#[arg(long, value_name = "PORT")]
pub port: Option<u16>,

/// Run in the foreground (do not double-fork). Useful for development.
/// Run in the foreground, owned by the current terminal or supervisor.
#[arg(long)]
pub foreground: bool,

Expand Down
43 changes: 7 additions & 36 deletions crates/bsk-cli/src/cli/ensure_daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@
//! Flow (per design §3.1):
//! 1. Verify the daemon over IPC and return its discovery info.
//! 2. Only if no endpoint is listening and auto-start is enabled, spawn
//! `bsk daemon start` (the same binary), inheriting
//! `BSK_HOME` if set, and poll for verified IPC readiness until
//! the daemon directly through the shared background startup path,
//! inheriting `BSK_HOME` if set, and poll for verified IPC readiness until
//! [`SPAWN_DEADLINE`] elapses.
//! 3. If polling times out, return an error with hints.

use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::Duration;
use std::time::{Duration, Instant};

use anyhow::{Context, Result, ensure};

use crate::cli::daemon::StartArgs;
use crate::daemon::info::DaemonInfo;
use crate::daemon::probe::{self, PROBE_TIMEOUT, Probe};
use crate::daemon::start::start_background;

/// Maximum time to wait for an auto-spawned daemon to become ready.
pub const SPAWN_DEADLINE: Duration = Duration::from_millis(3_000);
Expand All @@ -33,39 +33,10 @@ pub(crate) const AUTO_START_DISABLED_HINT: &str = "automatic daemon startup is d
/// Return verified discovery info, starting a daemon only when its discovery
/// file or IPC listener is absent and auto-start is enabled.
pub fn ensure_daemon() -> Result<DaemonInfo> {
let deadline = Instant::now() + SPAWN_DEADLINE;
if let Probe::Ready(daemon) = probe::probe(PROBE_TIMEOUT)? {
return Ok(daemon.info);
}
ensure!(auto_start_enabled(), AUTO_START_DISABLED_HINT);
spawn_daemon()?;
probe::wait_for_ready(SPAWN_DEADLINE)
.map(|daemon| daemon.info)
.with_context(|| "auto-spawned daemon failed to become ready in time")
}

fn spawn_daemon() -> Result<()> {
let exe = bsk_executable()?;
let mut cmd = Command::new(exe);
cmd.arg("daemon")
.arg("start")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
// The child re-uses inherited env (BSK_HOME etc), so tests that set
// a temp home work transparently.
let output = cmd
.output()
.context("spawn `bsk daemon start` for auto-spawn")?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"`bsk daemon start` exited with status {:?}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(())
}

fn bsk_executable() -> Result<PathBuf> {
std::env::current_exe().context("locate current executable for auto-spawn")
start_background(&StartArgs::default(), deadline).context("automatic daemon startup failed")
}
173 changes: 14 additions & 159 deletions crates/bsk-cli/src/cli/update/windows.rs
Original file line number Diff line number Diff line change
@@ -1,76 +1,15 @@
//! 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.
//! The update helper inherits only its dedicated stdio handles. Keep its
//! existing Job policy: daemon startup separately requires verified breakaway.

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,
};
use windows_sys::Win32::System::Threading::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};

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()) };
}
}
use crate::windows_process;
pub(super) use crate::windows_process::Process as Helper;

pub(super) fn spawn(
script: &Path,
Expand All @@ -81,12 +20,10 @@ pub(super) fn spawn(
) -> 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());
let application = Path::new(&root).join("System32/cmd.exe");
// /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 command = 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()),
Expand All @@ -109,95 +46,13 @@ pub(super) fn spawn(
.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()
windows_process::spawn(
application.as_os_str(),
command,
&env,
[&input, &log, &log],
CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP,
)
}
Loading
Loading