Files
codex/codex-rs/app-server-daemon/src/backend/windows.rs
T
Eric Traut 91608236ea Support managed daemon updates on Windows (#42392)
## What changed

- Run the daemon update loop on Windows using the non-interactive PowerShell installer.
- Restart the managed app server after updates and hand updater ownership to a changed managed executable through a readiness handshake.
- Contain installer descendants in a Windows job while allowing app-server and successor updater processes to break away.
- Preserve or restore PID ownership when successor startup fails, without terminating a process that reused the PID.

## Testing

- Cover non-interactive installer success and script failure on Windows.
- Cover updater handoff, readiness, rollback, inaccessible processes, and PID reuse.

GitOrigin-RevId: 5178c01124b4dd86b507b53f5093ec9a9d48e12c
2026-09-03 00:07:34 +00:00

197 lines
7.2 KiB
Rust

//! Windows process identity and file locks. Keep a process handle across shutdown
//! so PID reuse can never redirect forced termination to a different process.
use std::io;
use std::os::windows::io::AsRawHandle;
use std::os::windows::io::FromRawHandle;
use std::os::windows::io::OwnedHandle;
use anyhow::Context;
use anyhow::Result;
use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER;
use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION;
use windows_sys::Win32::Foundation::FILETIME;
use windows_sys::Win32::Foundation::WAIT_OBJECT_0;
use windows_sys::Win32::Foundation::WAIT_TIMEOUT;
use windows_sys::Win32::Storage::FileSystem::LOCKFILE_EXCLUSIVE_LOCK;
use windows_sys::Win32::Storage::FileSystem::LOCKFILE_FAIL_IMMEDIATELY;
use windows_sys::Win32::Storage::FileSystem::LockFileEx;
use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
use windows_sys::Win32::System::JobObjects::CreateJobObjectW;
use windows_sys::Win32::System::JobObjects::IsProcessInJob;
use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_BREAKAWAY_OK;
use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
use windows_sys::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION;
use windows_sys::Win32::System::JobObjects::JobObjectExtendedLimitInformation;
use windows_sys::Win32::System::JobObjects::SetInformationJobObject;
use windows_sys::Win32::System::Threading::GetCurrentProcess;
use windows_sys::Win32::System::Threading::GetProcessId;
use windows_sys::Win32::System::Threading::GetProcessTimes;
use windows_sys::Win32::System::Threading::OpenProcess;
use windows_sys::Win32::System::Threading::PROCESS_ACCESS_RIGHTS;
use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION;
use windows_sys::Win32::System::Threading::PROCESS_SYNCHRONIZE;
use windows_sys::Win32::System::Threading::PROCESS_TERMINATE;
use windows_sys::Win32::System::Threading::TerminateProcess;
use windows_sys::Win32::System::Threading::WaitForSingleObject;
pub(super) struct Process(OwnedHandle);
impl Process {
pub(super) fn open(pid: u32) -> Result<Option<Self>> {
Self::open_with_access(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE)
}
fn open_with_access(pid: u32, access: PROCESS_ACCESS_RIGHTS) -> Result<Option<Self>> {
let handle = unsafe {
OpenProcess(access, /*binherithandle*/ 0, pid)
};
if handle == 0 {
let err = io::Error::last_os_error();
return if err.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) {
Ok(None)
} else {
Err(err).context("failed to open daemon process")
};
}
Ok(Some(Self(unsafe {
OwnedHandle::from_raw_handle(handle as _)
})))
}
pub(super) fn start_time(&self) -> Result<String> {
let mut created: FILETIME = unsafe { std::mem::zeroed() };
let mut exited = created;
let mut kernel = created;
let mut user = created;
if unsafe {
GetProcessTimes(
self.0.as_raw_handle() as _,
&mut created,
&mut exited,
&mut kernel,
&mut user,
)
} == 0
{
return Err(io::Error::last_os_error()).context("failed to query daemon creation time");
}
Ok(
((u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime))
.to_string(),
)
}
pub(super) fn is_running(&self) -> Result<bool> {
match unsafe {
WaitForSingleObject(self.0.as_raw_handle() as _, /*dwmilliseconds*/ 0)
} {
WAIT_TIMEOUT => Ok(true),
WAIT_OBJECT_0 => Ok(false),
_ => Err(io::Error::last_os_error()).context("failed to wait for daemon process"),
}
}
pub(super) fn ensure_detached(&self) -> Result<()> {
let mut in_job = 0;
if unsafe {
IsProcessInJob(
self.0.as_raw_handle() as _,
/*jobhandle*/ 0,
&mut in_job,
)
} == 0
{
let error = io::Error::last_os_error();
self.terminate()?;
return Err(error).context("failed to verify daemon detachment");
}
if in_job != 0 {
self.terminate()?;
anyhow::bail!(
"host Job Object prevents daemon detachment; start from a host that allows breakaway"
);
}
Ok(())
}
pub(super) fn terminate(&self) -> Result<()> {
if !self.is_running()? {
return Ok(());
}
let pid = unsafe { GetProcessId(self.0.as_raw_handle() as _) };
let Some(target) = Self::open_with_access(
pid,
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE | PROCESS_TERMINATE,
)?
else {
return Ok(());
};
// Keep the original identity handle alive and validate the handle that
// will actually be terminated, rather than trusting a second PID lookup.
if target.start_time()? != self.start_time()? || !target.is_running()? {
return Ok(());
}
if unsafe {
TerminateProcess(target.0.as_raw_handle() as _, /*uexitcode*/ 1)
} == 0
{
return Err(io::Error::last_os_error()).context("failed to terminate daemon process");
}
Ok(())
}
}
pub(crate) fn try_lock_file(file: &tokio::fs::File) -> Result<bool> {
let mut overlapped = unsafe { std::mem::zeroed() };
if unsafe {
LockFileEx(
file.as_raw_handle() as _,
LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
/*dwreserved*/ 0,
/*nnumberofbytestolocklow*/ 1,
/*nnumberofbytestolockhigh*/ 0,
&mut overlapped,
)
} != 0
{
return Ok(true);
}
let err = io::Error::last_os_error();
if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32) {
return Ok(false);
}
Err(err).context("failed to lock daemon state")
}
// Keep installer descendants bounded by the updater's lifetime, while allowing
// app-server launches and successor updaters to break away from this job.
pub(crate) fn updater_job() -> Result<OwnedHandle> {
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job == 0 {
return Err(io::Error::last_os_error()).context("failed to create updater job");
}
let owned = unsafe { OwnedHandle::from_raw_handle(job as _) };
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
limits.BasicLimitInformation.LimitFlags =
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
if unsafe {
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
(&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
std::mem::size_of_val(&limits) as u32,
)
} == 0
|| unsafe { AssignProcessToJobObject(job, GetCurrentProcess()) } == 0
{
return Err(io::Error::last_os_error())
.context("failed to contain updater installer processes");
}
Ok(owned)
}
#[cfg(test)]
#[path = "windows_tests.rs"]
mod tests;