From 3e19e556bcfc2e293cc26db21627193505fbc3f1 Mon Sep 17 00:00:00 2001 From: RomainROCH Date: Wed, 7 Oct 2026 20:03:00 +0200 Subject: [PATCH 1/3] fix: share status fallback connection budget Keep the initial status discovery grace and let the legacy control fallback use only the remaining absolute two-second connection retry budget. Preserve ordinary control retries, protocol and access checks. Local checkpoint: formatting and documentation checks passed. Offline compilation is blocked by missing Cargo archives; Rust tests and before/after CLI measurements have not run. Do not treat this checkpoint as runtime verified. --- CHANGELOG.md | 1 + PLAN.md | 2 +- README.md | 4 +- docs/ARCHITECTURE.md | 4 +- docs/DEVELOPMENT.md | 9 +- src/ipc.rs | 83 ++++++- src/main.rs | 20 +- tests/status_connection_budget.rs | 386 ++++++++++++++++++++++++++++++ 8 files changed, 488 insertions(+), 21 deletions(-) create mode 100644 tests/status_connection_budget.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a0caeeb..1a01075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. Target version: `0.2.1`. This is a local candidate, not a published release. ### Changed +- Share one two-second connection-retry deadline across the read-only status pipe and its legacy control fallback, preserving the initial status startup grace and ordinary control retries. The unavailable-endpoint wait targets about two seconds plus scheduling overhead; connected reply reads are not bounded by this deadline. This local implementation still needs Windows validation. - Gate release tags on matching package/lock/manifest versions, main-branch provenance and successful Windows checks for the exact commit. Build and transfer the same checked binary into a draft only; publication remains manual. - Bound newline-terminated control IPC requests to 4096 bytes and one total second, preserving independent status availability and rejection/recovery behavior. - Validate autostart path access with identification tokens, and record disposable-VM idle-power acceptance without claiming physical hardware coverage. diff --git a/PLAN.md b/PLAN.md index 956324b..efcd581 100644 --- a/PLAN.md +++ b/PLAN.md @@ -57,7 +57,7 @@ Keep improving confidence in the features that already exist: - tighten documentation around actual runtime behavior and limits - improve manual validation guidance for shutdown, sign-out, sleep, hibernate, remote shutdown, autostart, and elevation-sensitive paths - fix bugs or rough edges in tray, IPC, logging, and Task Scheduler flows -- reduce status fallback latency when the session mutex is occupied but both IPC endpoints are unavailable (up to roughly four seconds of sequential retries); the ordinary inactive path already returns without pipe retries +- validate the local status fallback latency implementation on Windows: status-pipe and legacy control-pipe connection retries share one absolute two-second deadline, preserving the initial status retry grace and using any remaining budget for fallback (target: about two seconds plus scheduling overhead when both endpoints are unavailable). Build/Rust checks and CLI latency measurements remain pending. Connected response reads are outside this budget; the inactive path still avoids retries and normal control commands retain their 20-attempt policy - keep admin versus non-admin behavior explicit - keep LTSC-style Layer 3 skip behavior documented as normal when the UpdateOrchestrator reboot task is absent diff --git a/README.md b/README.md index 6452af8..ec1b353 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,9 @@ Read-only commands stay non-elevated: - `--status` reads status through the dedicated session-scoped `WardoffStatus` named pipe when a primary runtime is active - `--log` and `--log --tail N` read the rotating JSONL log files directly - those read-only commands stay attached to the calling shell so direct PowerShell CLI invocations can capture their output inline -- when no primary runtime owns the session mutex, `--status` returns `{"state":"inactive"}` without retrying the pipes; if the mutex is occupied but both IPC endpoints are unavailable, the fallback retries can still take about four seconds +- when no primary runtime owns the session mutex, `--status` returns `{"state":"inactive"}` without retrying the pipes +- when the mutex is occupied, the local implementation shares one absolute two-second connection-retry deadline between `WardoffStatus` and the legacy `WardoffControl` status fallback. It preserves the initial status retry grace, and an early status failure leaves the remaining connection budget for fallback. The target when both endpoints are unavailable is about two seconds plus scheduling overhead; Windows validation and CLI latency measurements remain pending +- that connection budget does not limit response reads after a pipe connects, so it is not an end-to-end timeout for `--status` State-changing/default behavior uses the normal runtime path: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 467fab4..422e1d9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -280,7 +280,9 @@ The control pipe is now created with explicit local-only security instead of the - a medium-integrity mandatory label allows the same interactive Windows user to send control commands to an elevated primary runtime - if a caller still hits access denied, Wardoff maps that to a clear product message instead of returning a raw Win32 pipe error -`handle_status_request` first probes the session mutex. If it can claim the primary instance, it releases that temporary claim and returns `{"state":"inactive"}` without connecting to either pipe. When the mutex is already occupied, it tries the read-only status pipe and then the control fallback. If both endpoints are unavailable, their sequential retries (20 attempts with a 100 ms delay per pipe) can still take roughly four seconds. That delay belongs to the occupied-mutex fallback, not the ordinary inactive path or UAC. +`handle_status_request` first probes the session mutex. If it can claim the primary instance, it releases that temporary claim and returns `{"state":"inactive"}` without connecting to either pipe. When the mutex is already occupied, it tries the read-only status pipe and then the legacy control fallback. + +The local status-latency change gives those connection retries one absolute two-second deadline, preserving the status pipe's initial startup grace while leaving only the remaining budget for the fallback. This avoids starting a second full connection wait. The intended unavailable-endpoint wait is about two seconds plus scheduling overhead; connected response reads remain outside that deadline, so it is not an end-to-end command timeout. Normal control commands retain their existing 20-attempt connection policy, and error classification and access checks are unchanged. Windows runtime validation of this local implementation remains pending. ### Why the UI thread handles requests diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5911880..138cfc0 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -69,8 +69,13 @@ Effects remain in the existing runtime and subsystem owners. The two pipes have different contracts: status reads must remain available without elevation; control commands retain same-user/session access checks. The ordinary inactive status path checks the session mutex and returns without -pipe retries. If the mutex is occupied but neither pipe responds, the sequential -fallback can take about four seconds; that case is tracked in [PLAN.md](../PLAN.md). +pipe retries. The local status-latency change shares one absolute two-second +connection deadline between the status pipe and its legacy control fallback, +preserving the initial status startup grace. The fallback uses the remaining +budget rather than starting another full wait. This targets about two seconds +plus scheduling overhead when both endpoints are unavailable; connected reply +reads are outside the budget, and ordinary control commands keep their existing +20-attempt policy. Windows validation remains pending in [PLAN.md](../PLAN.md). ## Validation diff --git a/src/ipc.rs b/src/ipc.rs index bb0d7bc..2337b46 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -238,8 +238,22 @@ impl IpcServer { /// Sends a JSON control request to the primary Wardoff runtime. pub(crate) fn send_request(request: &IpcRequest) -> Result { + send_request_with_connect_deadline(request, None) +} + +/// Requests status through the legacy control pipe using the remaining discovery budget. +pub(crate) fn send_status_request_with_connect_deadline( + connect_deadline: Instant, +) -> Result { + send_request_with_connect_deadline(&IpcRequest::Status, Some(connect_deadline)) +} + +fn send_request_with_connect_deadline( + request: &IpcRequest, + connect_deadline: Option, +) -> Result { let control_pipe_path = current_control_pipe_path().map_err(ClientError::Transport)?; - let mut pipe = connect_control_pipe(&control_pipe_path)?; + let mut pipe = connect_control_pipe(&control_pipe_path, connect_deadline)?; let payload = serde_json::to_string(request).map_err(|error| { ClientError::Transport(format!( "Wardoff could not serialize its IPC request: {error}" @@ -285,10 +299,10 @@ pub(crate) fn send_request(request: &IpcRequest) -> Result Result { +/// Reads status; an optional deadline limits connection retries, not a connected reply read. +pub(crate) fn read_status(connect_deadline: Option) -> Result { let status_pipe_path = current_status_pipe_path().map_err(ClientError::Transport)?; - let mut pipe = connect_status_pipe(&status_pipe_path)?; + let mut pipe = connect_status_pipe(&status_pipe_path, connect_deadline)?; let mut status_line = String::new(); let bytes_read = { let mut reader = BufReader::new(&mut pipe); @@ -1023,7 +1037,10 @@ fn write_status_output(pipe: &mut File, status: &StatusOutput) -> Result<(), Str .map_err(|error| format!("Wardoff could not flush its status reply: {error}")) } -fn connect_control_pipe(control_pipe_path: &str) -> Result { +fn connect_control_pipe( + control_pipe_path: &str, + connect_deadline: Option, +) -> Result { let mut last_error_code = None; for attempt in 0..PIPE_CONNECT_ATTEMPTS { @@ -1043,9 +1060,8 @@ fn connect_control_pipe(control_pipe_path: &str) -> Result { if code == ERROR_FILE_NOT_FOUND.0 as i32 || code == ERROR_PATH_NOT_FOUND.0 as i32 || code == ERROR_PIPE_BUSY.0 as i32 - ) && attempt + 1 < PIPE_CONNECT_ATTEMPTS + ) && wait_before_pipe_retry(attempt, connect_deadline) { - thread::sleep(PIPE_CONNECT_DELAY); continue; } @@ -1057,7 +1073,10 @@ fn connect_control_pipe(control_pipe_path: &str) -> Result { final_connect_error(control_pipe_path, last_error_code) } -fn connect_status_pipe(status_pipe_path: &str) -> Result { +fn connect_status_pipe( + status_pipe_path: &str, + connect_deadline: Option, +) -> Result { let mut last_error_code = None; for attempt in 0..PIPE_CONNECT_ATTEMPTS { @@ -1073,9 +1092,8 @@ fn connect_status_pipe(status_pipe_path: &str) -> Result { if code == ERROR_FILE_NOT_FOUND.0 as i32 || code == ERROR_PATH_NOT_FOUND.0 as i32 || code == ERROR_PIPE_BUSY.0 as i32 - ) && attempt + 1 < PIPE_CONNECT_ATTEMPTS + ) && wait_before_pipe_retry(attempt, connect_deadline) { - thread::sleep(PIPE_CONNECT_DELAY); continue; } @@ -1087,6 +1105,28 @@ fn connect_status_pipe(status_pipe_path: &str) -> Result { final_connect_error(status_pipe_path, last_error_code) } +fn pipe_retry_delay(attempt: usize, remaining: Option) -> Option { + if attempt + 1 >= PIPE_CONNECT_ATTEMPTS { + return None; + } + let delay = remaining.map_or(PIPE_CONNECT_DELAY, |remaining| { + remaining.min(PIPE_CONNECT_DELAY) + }); + (!delay.is_zero()).then_some(delay) +} + +fn wait_before_pipe_retry(attempt: usize, connect_deadline: Option) -> bool { + let remaining = + connect_deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); + match pipe_retry_delay(attempt, remaining) { + Some(delay) => { + thread::sleep(delay); + true + } + None => false, + } +} + fn final_connect_error(path: &str, last_error_code: Option) -> Result { match last_error_code { Some(code) @@ -1285,6 +1325,29 @@ impl Drop for LocalAllocatedWideString { mod tests { use super::*; + #[test] + fn connection_retries_keep_the_original_limit_without_a_deadline() { + assert_eq!(pipe_retry_delay(0, None), Some(PIPE_CONNECT_DELAY)); + assert_eq!( + pipe_retry_delay(PIPE_CONNECT_ATTEMPTS - 2, None), + Some(PIPE_CONNECT_DELAY) + ); + assert_eq!(pipe_retry_delay(PIPE_CONNECT_ATTEMPTS - 1, None), None); + } + + #[test] + fn connection_retries_only_sleep_within_the_remaining_budget() { + assert_eq!( + pipe_retry_delay(0, Some(Duration::from_millis(40))), + Some(Duration::from_millis(40)) + ); + assert_eq!(pipe_retry_delay(0, Some(Duration::ZERO)), None); + assert_eq!( + pipe_retry_delay(0, Some(Duration::from_secs(1))), + Some(PIPE_CONNECT_DELAY) + ); + } + #[test] fn control_pipe_access_denied_maps_to_product_message() { let error = std::io::Error::from_raw_os_error(ERROR_ACCESS_DENIED.0 as i32); diff --git a/src/main.rs b/src/main.rs index 76d3926..1b7655d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,8 +23,8 @@ use crate::instance::{ claim_primary_instance, claim_primary_instance_with_retry, InstanceClaim, InstanceGuard, }; use crate::ipc::{ - read_status, send_request, ClientError, IpcRequest, IpcResponse, IpcServer, PendingRequest, - IPC_WAKE_MESSAGE, + read_status, send_request, send_status_request_with_connect_deadline, ClientError, IpcRequest, + IpcResponse, IpcServer, PendingRequest, IPC_WAKE_MESSAGE, }; use crate::logger::EventSource; use crate::runtime_policy::{ @@ -54,6 +54,7 @@ use windows::Win32::UI::WindowsAndMessaging::{ const ELEVATED_RELAUNCH_MUTEX_RETRY_ATTEMPTS: usize = 20; const ELEVATED_RELAUNCH_MUTEX_RETRY_DELAY: Duration = Duration::from_millis(100); +const STATUS_PIPE_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2); // Callback bridge for the Win32 message loop on the application thread. `run` // registers it only while its local Application is alive and clears callbacks @@ -335,18 +336,25 @@ fn handle_status_request() -> Result> { } fn handle_secondary_status_request() -> Result> { - match read_status() { + // Give the read-only pipe its startup grace, then share the remaining discovery + // budget with the legacy control fallback. Connected reply reads are unchanged. + let connect_deadline = Instant::now() + STATUS_PIPE_DISCOVERY_TIMEOUT; + match read_status(Some(connect_deadline)) { Ok(status) => { println!("{}", status.to_json()?); Ok(0) } - Err(ClientError::Unavailable) => handle_secondary_status_request_via_control_pipe(), + Err(ClientError::Unavailable) => { + handle_secondary_status_request_via_control_pipe(connect_deadline) + } Err(ClientError::Transport(message)) => Err(Box::new(other_error(message))), } } -fn handle_secondary_status_request_via_control_pipe() -> Result> { - match send_request(&IpcRequest::Status) { +fn handle_secondary_status_request_via_control_pipe( + connect_deadline: Instant, +) -> Result> { + match send_status_request_with_connect_deadline(connect_deadline) { Ok(IpcResponse::Status { status }) => { println!("{}", status.to_json()?); Ok(0) diff --git a/tests/status_connection_budget.rs b/tests/status_connection_budget.rs new file mode 100644 index 0000000..0a2616c --- /dev/null +++ b/tests/status_connection_budget.rs @@ -0,0 +1,386 @@ +//! Exercises production IPC clients/workers on process-unique, inert endpoints. +//! No protection runtime, singleton, autostart, logger, or production endpoint is started. +#![cfg(windows)] +#![allow(dead_code)] + +use std::fs::{File, OpenOptions}; +use std::os::windows::io::{FromRawHandle, RawHandle}; +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc, Arc, +}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; +use windows::core::PCWSTR; +use windows::Win32::Foundation::{GetLastError, ERROR_PIPE_CONNECTED}; +use windows::Win32::Storage::FileSystem::{FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_ACCESS_OUTBOUND}; +use windows::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, WaitNamedPipeW, PIPE_READMODE_MESSAGE, + PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_MESSAGE, PIPE_WAIT, +}; +use windows::Win32::System::Threading::GetCurrentThreadId; +use windows::Win32::UI::WindowsAndMessaging::{PeekMessageW, MSG, PM_NOREMOVE}; + +static MALFORMED_STATUS_REPLY: AtomicBool = AtomicBool::new(false); + +mod blocker { + pub(crate) enum BlockerMode { + Block, + Allow, + } +} + +mod cli { + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] + pub(crate) struct StatusOutput { + pub(crate) state: String, + } + + impl StatusOutput { + pub(crate) fn to_json(&self) -> Result { + if super::MALFORMED_STATUS_REPLY.load(std::sync::atomic::Ordering::SeqCst) { + return Ok("not JSON".to_string()); + } + serde_json::to_string(self) + } + } +} + +mod logger { + pub(crate) enum EventSource { + Ipc, + } + + pub(crate) fn log_event(_: &str, _: EventSource, _: impl Into, _: bool) {} +} + +mod session_scope { + use std::cell::RefCell; + + #[derive(Default)] + struct ClientPaths { + control: Option, + status: Option, + } + + thread_local! { + static CLIENT_PATHS: RefCell = const { + RefCell::new(ClientPaths { control: None, status: None }) + }; + } + + // Worker threads retain canonical names; aliases affect only this caller thread. + pub(crate) struct ClientAliases(ClientPaths); + + impl ClientAliases { + pub(crate) fn new(control: Option, status: Option) -> Self { + Self(CLIENT_PATHS.with(|paths| paths.replace(ClientPaths { control, status }))) + } + + pub(crate) fn actual() -> Self { + Self::new(None, None) + } + } + + impl Drop for ClientAliases { + fn drop(&mut self) { + CLIENT_PATHS.with(|paths| { + paths.replace(std::mem::take(&mut self.0)); + }); + } + } + + pub(crate) fn path(kind: &str) -> String { + format!( + r"\\.\pipe\WardoffStatusBudget-{kind}-{}", + std::process::id() + ) + } + + pub(crate) fn current_control_pipe_path() -> Result { + Ok(CLIENT_PATHS.with(|paths| { + paths + .borrow() + .control + .clone() + .unwrap_or_else(|| path("Control")) + })) + } + + pub(crate) fn current_status_pipe_path() -> Result { + Ok(CLIENT_PATHS.with(|paths| { + paths + .borrow() + .status + .clone() + .unwrap_or_else(|| path("Status")) + })) + } +} + +#[path = "../src/ipc.rs"] +mod ipc; + +struct TestRuntime { + server: ipc::IpcServer, + ui_thread: Option>, + dispatched: Arc, +} + +impl TestRuntime { + fn start() -> Self { + let _actual = session_scope::ClientAliases::actual(); + let (request_tx, request_rx) = mpsc::channel::(); + let (ready_tx, ready_rx) = mpsc::channel(); + let dispatched = Arc::new(AtomicUsize::new(0)); + let ui_dispatched = Arc::clone(&dispatched); + let ui_thread = thread::spawn(move || { + let mut message = MSG::default(); + unsafe { + let _ = PeekMessageW(&mut message, None, 0, 0, PM_NOREMOVE); + } + ready_tx + .send(unsafe { GetCurrentThreadId() }) + .expect("UI ready"); + for pending in request_rx { + ui_dispatched.fetch_add(1, Ordering::SeqCst); + let response = match pending.request { + ipc::IpcRequest::Status => ipc::IpcResponse::Status { + status: cli::StatusOutput { + state: "allow".into(), + }, + }, + _ => ipc::IpcResponse::Ok, + }; + let _ = pending.response_tx.send(response); + } + }); + let server = ipc::IpcServer::start(request_tx, ready_rx.recv().expect("UI thread ID")) + .expect("isolated IPC startup"); + Self { + server, + ui_thread: Some(ui_thread), + dispatched, + } + } + + fn stop(&mut self) { + // Shutdown's own clients must never inherit a masked endpoint from a failed assertion. + let _actual = session_scope::ClientAliases::actual(); + self.server.shutdown().expect("clean IPC shutdown"); + if let Some(ui_thread) = self.ui_thread.take() { + ui_thread.join().expect("inert UI joined"); + } + } +} + +impl Drop for TestRuntime { + fn drop(&mut self) { + self.stop(); + } +} + +struct MalformedReply; + +impl MalformedReply { + fn enable() -> Self { + MALFORMED_STATUS_REPLY.store(true, Ordering::SeqCst); + Self + } +} + +impl Drop for MalformedReply { + fn drop(&mut self) { + MALFORMED_STATUS_REPLY.store(false, Ordering::SeqCst); + } +} + +// One connected instance makes a second open return ERROR_PIPE_BUSY without +// blocking a UI consumer or altering the production status server's lifecycle. +struct BusyStatusPipe { + server: File, + client: File, +} + +impl BusyStatusPipe { + fn new(path: &str) -> Self { + let wide: Vec = path.encode_utf16().chain(std::iter::once(0)).collect(); + let handle = unsafe { + CreateNamedPipeW( + PCWSTR(wide.as_ptr()), + PIPE_ACCESS_OUTBOUND | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + 1, + 4096, + 4096, + 0, + None, + ) + }; + assert!(!handle.is_invalid(), "create isolated busy status pipe"); + let server = unsafe { File::from_raw_handle(handle.0 as RawHandle) }; + let client = OpenOptions::new() + .read(true) + .open(path) + .expect("occupy status instance"); + match unsafe { ConnectNamedPipe(handle, None) } { + Ok(()) => {} + Err(_) if unsafe { GetLastError() } == ERROR_PIPE_CONNECTED => {} + Err(error) => panic!("connect occupied status instance: {error}"), + } + Self { server, client } + } +} + +// Mirrors the fallback decision only; the real CLI is checked separately. +fn query_status(deadline: Instant) -> Result { + match ipc::read_status(Some(deadline)) { + Err(ipc::ClientError::Unavailable) => { + match ipc::send_status_request_with_connect_deadline(deadline)? { + ipc::IpcResponse::Status { status } => Ok(status), + _ => panic!("unexpected control status response"), + } + } + result => result, + } +} + +fn assert_allow(result: Result) { + match result { + Ok(status) => assert_eq!(status.state, "allow"), + Err(ipc::ClientError::Unavailable) => panic!("status was unavailable"), + Err(ipc::ClientError::Transport(message)) => panic!("status transport: {message}"), + } +} + +fn wait_for_available(kind: &str) { + let path = session_scope::path(kind); + let wide: Vec = path.encode_utf16().chain(std::iter::once(0)).collect(); + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if unsafe { WaitNamedPipeW(PCWSTR(wide.as_ptr()), 50) }.is_ok() { + return; + } + assert!( + Instant::now() < deadline, + "{kind} endpoint did not become available" + ); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn isolated_status_connections_share_a_budget_and_preserve_fallback_rules() { + // 1. Two absent endpoints share one short budget, not two retry windows. + let started = Instant::now(); + let deadline = started + Duration::from_millis(140); + assert!(matches!( + ipc::read_status(Some(deadline)), + Err(ipc::ClientError::Unavailable) + )); + assert!( + Instant::now() >= deadline, + "missing status should consume its retry budget" + ); + let fallback_started = Instant::now(); + assert!(matches!( + ipc::send_status_request_with_connect_deadline(deadline), + Err(ipc::ClientError::Unavailable) + )); + let fallback_elapsed = fallback_started.elapsed(); + assert!( + fallback_elapsed < Duration::from_millis(300), + "expired absent control retried for {fallback_elapsed:?}" + ); + assert!( + started.elapsed() < Duration::from_millis(700), + "absent endpoints renewed the budget" + ); + eprintln!( + "absent endpoints: {:?}; expired fallback: {fallback_elapsed:?}", + started.elapsed() + ); + + // 2. A status server becoming ready within the budget is still reachable. + let started = Instant::now(); + let launch = thread::spawn(|| { + thread::sleep(Duration::from_millis(150)); + TestRuntime::start() + }); + let status = ipc::read_status(Some(started + Duration::from_secs(2))); + let mut runtime = launch.join().expect("delayed inert runtime started"); + assert_allow(status); + assert!(started.elapsed() >= Duration::from_millis(100)); + + // 3. An already expired deadline still permits one immediate open on either pipe. + // Wait for the worker's replacement instance without consuming a connection. + wait_for_available("Status"); + let expired = Instant::now() - Duration::from_millis(1); + assert_allow(ipc::read_status(Some(expired))); + wait_for_available("Control"); + match ipc::send_status_request_with_connect_deadline(expired) { + Ok(ipc::IpcResponse::Status { status }) => assert_eq!(status.state, "allow"), + _ => panic!("available control endpoint was skipped after deadline"), + } + + // 4. A legacy control-only peer is usable after the missing status consumed the budget. + let before = runtime.dispatched.load(Ordering::SeqCst); + { + let _alias = + session_scope::ClientAliases::new(None, Some(session_scope::path("MissingStatus"))); + let started = Instant::now(); + assert_allow(query_status(started + Duration::from_millis(140))); + assert!(started.elapsed() >= Duration::from_millis(100)); + assert!(started.elapsed() < Duration::from_millis(700)); + } + assert_eq!(runtime.dispatched.load(Ordering::SeqCst), before + 1); + + // 5. Malformed replies and busy status endpoints are transport failures, not fallback cues. + let before = runtime.dispatched.load(Ordering::SeqCst); + { + let _malformed = MalformedReply::enable(); + assert!( + matches!(query_status(Instant::now() + Duration::from_millis(140)), + Err(ipc::ClientError::Transport(message)) if message.contains("malformed status JSON")) + ); + } + assert_eq!( + runtime.dispatched.load(Ordering::SeqCst), + before + 1, + "malformed status triggered control fallback" + ); + let busy_path = session_scope::path("BusyStatus"); + { + let _busy = BusyStatusPipe::new(&busy_path); + let _alias = session_scope::ClientAliases::new(None, Some(busy_path)); + assert!( + matches!(query_status(Instant::now() + Duration::from_millis(140)), + Err(ipc::ClientError::Transport(message)) if message.contains("busy")) + ); + } + assert_eq!( + runtime.dispatched.load(Ordering::SeqCst), + before + 1, + "busy status triggered control fallback" + ); + + // 6. The ordinary command path and orderly cleanup retain their existing behavior. + assert!(matches!( + ipc::send_request(&ipc::IpcRequest::SetMode { + mode: ipc::PipeMode::Allow + }), + Ok(ipc::IpcResponse::Ok) + )); + assert_allow(ipc::read_status(None)); + runtime.stop(); + for path in [ + session_scope::path("Control"), + session_scope::path("Status"), + session_scope::path("BusyStatus"), + ] { + assert!( + OpenOptions::new().read(true).open(path).is_err(), + "owned endpoint survived shutdown" + ); + } +} From fefa5b92d94bf82beec3c2f8ef12ce36462750e5 Mon Sep 17 00:00:00 2001 From: RomainROCH Date: Wed, 7 Oct 2026 20:35:33 +0200 Subject: [PATCH 2/3] test: use BOOL for status pipe availability --- tests/status_connection_budget.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/status_connection_budget.rs b/tests/status_connection_budget.rs index 0a2616c..a89a2d6 100644 --- a/tests/status_connection_budget.rs +++ b/tests/status_connection_budget.rs @@ -258,7 +258,7 @@ fn wait_for_available(kind: &str) { let wide: Vec = path.encode_utf16().chain(std::iter::once(0)).collect(); let deadline = Instant::now() + Duration::from_secs(3); loop { - if unsafe { WaitNamedPipeW(PCWSTR(wide.as_ptr()), 50) }.is_ok() { + if unsafe { WaitNamedPipeW(PCWSTR(wide.as_ptr()), 50) }.as_bool() { return; } assert!( From c06309c9e3d52b4519d9057766656715b8b9fa3b Mon Sep 17 00:00:00 2001 From: RomainROCH Date: Wed, 7 Oct 2026 20:57:44 +0200 Subject: [PATCH 3/3] docs: record isolated status latency validation --- CHANGELOG.md | 2 +- PLAN.md | 2 +- README.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/DEVELOPMENT.md | 10 +++++++++- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a01075..6661df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to this project will be documented in this file. Target version: `0.2.1`. This is a local candidate, not a published release. ### Changed -- Share one two-second connection-retry deadline across the read-only status pipe and its legacy control fallback, preserving the initial status startup grace and ordinary control retries. The unavailable-endpoint wait targets about two seconds plus scheduling overhead; connected reply reads are not bounded by this deadline. This local implementation still needs Windows validation. +- Share one two-second connection-retry deadline across the read-only status pipe and its legacy control fallback, preserving the initial status startup grace and ordinary control retries. The unavailable-endpoint wait targets about two seconds plus scheduling overhead; connected reply reads are not bounded by this deadline. - Gate release tags on matching package/lock/manifest versions, main-branch provenance and successful Windows checks for the exact commit. Build and transfer the same checked binary into a draft only; publication remains manual. - Bound newline-terminated control IPC requests to 4096 bytes and one total second, preserving independent status availability and rejection/recovery behavior. - Validate autostart path access with identification tokens, and record disposable-VM idle-power acceptance without claiming physical hardware coverage. diff --git a/PLAN.md b/PLAN.md index efcd581..0a8375a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -57,7 +57,7 @@ Keep improving confidence in the features that already exist: - tighten documentation around actual runtime behavior and limits - improve manual validation guidance for shutdown, sign-out, sleep, hibernate, remote shutdown, autostart, and elevation-sensitive paths - fix bugs or rough edges in tray, IPC, logging, and Task Scheduler flows -- validate the local status fallback latency implementation on Windows: status-pipe and legacy control-pipe connection retries share one absolute two-second deadline, preserving the initial status retry grace and using any remaining budget for fallback (target: about two seconds plus scheduling overhead when both endpoints are unavailable). Build/Rust checks and CLI latency measurements remain pending. Connected response reads are outside this budget; the inactive path still avoids retries and normal control commands retain their 20-attempt policy +- retain regression coverage for the local status fallback change: connection retries share one absolute two-second deadline across status and legacy fallback, preserving the initial status retry grace and remaining fallback budget. Formatting, compilation, strict Clippy and 74 Rust tests passed, including the eight focused IPC cases; one native Power Request test was excluded. Isolated CLI runs with an occupied session mutex and both endpoints unavailable measured median duration of 3882 ms before and 2110 ms after, with matching output after timestamp normalization; these measurements cover only that case. Connected response reads remain outside the budget; inactive status avoids retries and normal control retains 20 attempts - keep admin versus non-admin behavior explicit - keep LTSC-style Layer 3 skip behavior documented as normal when the UpdateOrchestrator reboot task is absent diff --git a/README.md b/README.md index ec1b353..87fec5f 100644 --- a/README.md +++ b/README.md @@ -175,7 +175,7 @@ Read-only commands stay non-elevated: - `--log` and `--log --tail N` read the rotating JSONL log files directly - those read-only commands stay attached to the calling shell so direct PowerShell CLI invocations can capture their output inline - when no primary runtime owns the session mutex, `--status` returns `{"state":"inactive"}` without retrying the pipes -- when the mutex is occupied, the local implementation shares one absolute two-second connection-retry deadline between `WardoffStatus` and the legacy `WardoffControl` status fallback. It preserves the initial status retry grace, and an early status failure leaves the remaining connection budget for fallback. The target when both endpoints are unavailable is about two seconds plus scheduling overhead; Windows validation and CLI latency measurements remain pending +- when the mutex is occupied, the local implementation shares one absolute two-second connection-retry deadline between `WardoffStatus` and the legacy `WardoffControl` status fallback. It preserves the initial status retry grace, and an early status failure leaves the remaining connection budget for fallback. The target when both endpoints are unavailable is about two seconds plus scheduling overhead - that connection budget does not limit response reads after a pipe connects, so it is not an end-to-end timeout for `--status` State-changing/default behavior uses the normal runtime path: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 422e1d9..9614121 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -282,7 +282,7 @@ The control pipe is now created with explicit local-only security instead of the `handle_status_request` first probes the session mutex. If it can claim the primary instance, it releases that temporary claim and returns `{"state":"inactive"}` without connecting to either pipe. When the mutex is already occupied, it tries the read-only status pipe and then the legacy control fallback. -The local status-latency change gives those connection retries one absolute two-second deadline, preserving the status pipe's initial startup grace while leaving only the remaining budget for the fallback. This avoids starting a second full connection wait. The intended unavailable-endpoint wait is about two seconds plus scheduling overhead; connected response reads remain outside that deadline, so it is not an end-to-end command timeout. Normal control commands retain their existing 20-attempt connection policy, and error classification and access checks are unchanged. Windows runtime validation of this local implementation remains pending. +The local status-latency change gives those connection retries one absolute two-second deadline, preserving the status pipe's initial startup grace while leaving only the remaining budget for the fallback. This avoids starting a second full connection wait. The intended unavailable-endpoint wait is about two seconds plus scheduling overhead; connected response reads remain outside that deadline, so it is not an end-to-end command timeout. Normal control commands retain their existing 20-attempt connection policy, and error classification and access checks are unchanged. ### Why the UI thread handles requests diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 138cfc0..c04c605 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -75,7 +75,15 @@ preserving the initial status startup grace. The fallback uses the remaining budget rather than starting another full wait. This targets about two seconds plus scheduling overhead when both endpoints are unavailable; connected reply reads are outside the budget, and ordinary control commands keep their existing -20-attempt policy. Windows validation remains pending in [PLAN.md](../PLAN.md). +20-attempt policy. + +Local validation of this change passed formatting, compilation checks, strict +Clippy and the safe Rust suite (74 tests). The native Power Request test +`blocker::sleep::tests::activation_reports_an_acquired_request_before_returning` +was explicitly excluded. The suite included the eight focused Windows IPC cases. In +isolated CLI runs with the session mutex occupied and both endpoints unavailable, +median duration decreased from 3882 ms to 2110 ms with matching output after +timestamp normalization. These measurements describe that test case. ## Validation