Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- 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.
Expand Down
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- 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

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- 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:

Expand Down
4 changes: 3 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

### Why the UI thread handles requests

Expand Down
17 changes: 15 additions & 2 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,21 @@ 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.

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

Expand Down
83 changes: 73 additions & 10 deletions src/ipc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,22 @@ impl IpcServer {

/// Sends a JSON control request to the primary Wardoff runtime.
pub(crate) fn send_request(request: &IpcRequest) -> Result<IpcResponse, ClientError> {
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<IpcResponse, ClientError> {
send_request_with_connect_deadline(&IpcRequest::Status, Some(connect_deadline))
}

fn send_request_with_connect_deadline(
request: &IpcRequest,
connect_deadline: Option<Instant>,
) -> Result<IpcResponse, ClientError> {
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}"
Expand Down Expand Up @@ -285,10 +299,10 @@ pub(crate) fn send_request(request: &IpcRequest) -> Result<IpcResponse, ClientEr
})
}

/// Reads the current runtime status through the dedicated read-only status pipe.
pub(crate) fn read_status() -> Result<StatusOutput, ClientError> {
/// Reads status; an optional deadline limits connection retries, not a connected reply read.
pub(crate) fn read_status(connect_deadline: Option<Instant>) -> Result<StatusOutput, ClientError> {
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);
Expand Down Expand Up @@ -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<File, ClientError> {
fn connect_control_pipe(
control_pipe_path: &str,
connect_deadline: Option<Instant>,
) -> Result<File, ClientError> {
let mut last_error_code = None;

for attempt in 0..PIPE_CONNECT_ATTEMPTS {
Expand All @@ -1043,9 +1060,8 @@ fn connect_control_pipe(control_pipe_path: &str) -> Result<File, ClientError> {
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;
}

Expand All @@ -1057,7 +1073,10 @@ fn connect_control_pipe(control_pipe_path: &str) -> Result<File, ClientError> {
final_connect_error(control_pipe_path, last_error_code)
}

fn connect_status_pipe(status_pipe_path: &str) -> Result<File, ClientError> {
fn connect_status_pipe(
status_pipe_path: &str,
connect_deadline: Option<Instant>,
) -> Result<File, ClientError> {
let mut last_error_code = None;

for attempt in 0..PIPE_CONNECT_ATTEMPTS {
Expand All @@ -1073,9 +1092,8 @@ fn connect_status_pipe(status_pipe_path: &str) -> Result<File, ClientError> {
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;
}

Expand All @@ -1087,6 +1105,28 @@ fn connect_status_pipe(status_pipe_path: &str) -> Result<File, ClientError> {
final_connect_error(status_pipe_path, last_error_code)
}

fn pipe_retry_delay(attempt: usize, remaining: Option<Duration>) -> Option<Duration> {
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<Instant>) -> 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<i32>) -> Result<File, ClientError> {
match last_error_code {
Some(code)
Expand Down Expand Up @@ -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);
Expand Down
20 changes: 14 additions & 6 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -335,18 +336,25 @@ fn handle_status_request() -> Result<i32, Box<dyn Error>> {
}

fn handle_secondary_status_request() -> Result<i32, Box<dyn Error>> {
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<i32, Box<dyn Error>> {
match send_request(&IpcRequest::Status) {
fn handle_secondary_status_request_via_control_pipe(
connect_deadline: Instant,
) -> Result<i32, Box<dyn Error>> {
match send_status_request_with_connect_deadline(connect_deadline) {
Ok(IpcResponse::Status { status }) => {
println!("{}", status.to_json()?);
Ok(0)
Expand Down
Loading
Loading