mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Support managed app-server lifecycle on Windows (#42381)
## Why The PID-managed app-server lifecycle previously supported Unix only, leaving Windows unable to start and stop the managed daemon. ## What changed - Launch the Windows daemon as a detached process and fail clearly when the host Job Object prevents detachment. - Add Windows file locking and process creation-time checks so stale or reused PIDs cannot cause an unrelated process to be terminated. - Request graceful shutdown through a shutdown file, then terminate the verified process after the grace period. - Prepare private daemon state directories consistently and pin the executable path across installer junction updates. ## Testing Add Windows tests for process-query permissions, reused and inaccessible PIDs, and stale-record cleanup. GitOrigin-RevId: 5ea579a7c3dc8bb6122ec3a02513836ccaacc41c
This commit is contained in:
1 parent
0588fc941c
commit
715294448f
9 files changed
+464
-37
No files matched your search
Generated
+1
@@ -2168,6 +2168,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -38,3 +38,6 @@ tokio-tungstenite = { workspace = true }
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_JobObjects", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading"] }
|
||||
@@ -1,4 +1,6 @@
|
||||
mod pid;
|
||||
#[cfg(windows)]
|
||||
pub(crate) mod windows;
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! PID reservations serialize launches and protect stale-record cleanup.
|
||||
//! PID reservations serialize detached launches; creation times protect stale-record cleanup.
|
||||
|
||||
use std::io::SeekFrom;
|
||||
use std::path::Path;
|
||||
@@ -8,7 +8,7 @@ use std::time::Duration;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
use codex_app_server_transport::REMOTE_CONTROL_DISABLED_ENV_VAR;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -26,7 +26,7 @@ const START_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const STDERR_LOG_TAIL_BYTES: u64 = 4096;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
|
||||
pub(crate) struct PidBackend {
|
||||
codex_bin: PathBuf,
|
||||
pid_file: PathBuf,
|
||||
@@ -68,7 +68,7 @@ enum PidFileState {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
|
||||
enum PidCommandKind {
|
||||
AppServer { remote_control_enabled: bool },
|
||||
UpdateLoop,
|
||||
@@ -115,7 +115,12 @@ impl PidBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(any(unix, windows))]
|
||||
pub(crate) async fn start(&self) -> Result<Option<u32>> {
|
||||
self.start_inner().await
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(crate) async fn start(&self) -> Result<Option<u32>> {
|
||||
bail!("pid-managed app-server startup is unsupported on this platform")
|
||||
}
|
||||
@@ -133,7 +138,21 @@ impl PidBackend {
|
||||
}
|
||||
|
||||
let pid = record.pid;
|
||||
#[cfg(unix)]
|
||||
self.terminate_process(pid)?;
|
||||
#[cfg(windows)]
|
||||
let process = {
|
||||
let Some(process) = super::windows::Process::open(pid)? else {
|
||||
continue;
|
||||
};
|
||||
if process.start_time()? != record.process_start_time {
|
||||
continue;
|
||||
}
|
||||
fs::write(self.pid_file.with_extension("shutdown"), pid.to_string())
|
||||
.await
|
||||
.context("failed to request daemon shutdown")?;
|
||||
process
|
||||
};
|
||||
let started_at = tokio::time::Instant::now();
|
||||
let deadline = tokio::time::Instant::now() + STOP_TIMEOUT;
|
||||
let mut forced = false;
|
||||
@@ -155,7 +174,10 @@ impl PidBackend {
|
||||
break;
|
||||
}
|
||||
if !forced && started_at.elapsed() >= STOP_GRACE_PERIOD {
|
||||
#[cfg(unix)]
|
||||
self.force_terminate_process(pid)?;
|
||||
#[cfg(windows)]
|
||||
process.terminate()?;
|
||||
forced = true;
|
||||
}
|
||||
sleep(STOP_POLL_INTERVAL).await;
|
||||
@@ -275,7 +297,7 @@ impl PidBackend {
|
||||
Ok(reservation_lock)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
async fn open_stderr_log(&self) -> Result<fs::File> {
|
||||
let stderr_log_file = stderr_log_file_for_pid_file(&self.pid_file);
|
||||
fs::OpenOptions::new()
|
||||
@@ -292,7 +314,7 @@ impl PidBackend {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
fn command_args(&self) -> Vec<&'static str> {
|
||||
match self.command_kind {
|
||||
PidCommandKind::AppServer {
|
||||
@@ -305,7 +327,7 @@ impl PidBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
fn command_env(&self) -> Option<(&'static str, &'static str)> {
|
||||
match self.command_kind {
|
||||
PidCommandKind::AppServer {
|
||||
@@ -325,6 +347,7 @@ impl PidBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn force_terminate_process(&self, pid: u32) -> Result<()> {
|
||||
match self.command_kind {
|
||||
PidCommandKind::AppServer { .. } => force_terminate_process(pid),
|
||||
@@ -441,17 +464,17 @@ fn force_terminate_process_group(pid: u32) -> Result<()> {
|
||||
Err(err).with_context(|| format!("failed to force terminate pid-managed updater group {pid}"))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn terminate_process(_pid: u32) -> Result<()> {
|
||||
bail!("pid-managed app-server shutdown is unsupported on this platform")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn force_terminate_process(_pid: u32) -> Result<()> {
|
||||
bail!("pid-managed app-server shutdown is unsupported on this platform")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn force_terminate_process_group(_pid: u32) -> Result<()> {
|
||||
bail!("pid-managed updater shutdown is unsupported on this platform")
|
||||
}
|
||||
@@ -469,13 +492,13 @@ async fn process_matches_record(record: &PidRecord) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
async fn process_matches_record(_record: &PidRecord) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
|
||||
enum EmptyPidReservation {
|
||||
Active,
|
||||
Stale,
|
||||
@@ -498,12 +521,12 @@ fn try_lock_file(file: &fs::File) -> Result<bool> {
|
||||
Err(err).context("failed to lock pid reservation")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn try_lock_file(_file: &fs::File) -> Result<bool> {
|
||||
bail!("pid-managed app-server startup is unsupported on this platform")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
async fn reservation_lock_is_active(path: &Path) -> Result<bool> {
|
||||
let file = match fs::OpenOptions::new()
|
||||
.write(true)
|
||||
@@ -524,12 +547,12 @@ async fn reservation_lock_is_active(path: &Path) -> Result<bool> {
|
||||
Ok(!try_lock_file(&file)?)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
async fn reservation_lock_is_active(_path: &Path) -> Result<bool> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(any(unix, windows))]
|
||||
async fn inspect_empty_pid_reservation(
|
||||
pid_path: &Path,
|
||||
lock_path: &Path,
|
||||
@@ -572,7 +595,7 @@ async fn inspect_empty_pid_reservation(
|
||||
Ok(EmptyPidReservation::Record(record))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
async fn inspect_empty_pid_reservation(
|
||||
_pid_path: &Path,
|
||||
_lock_path: &Path,
|
||||
@@ -600,10 +623,53 @@ async fn read_process_start_time(pid: u32) -> Result<String> {
|
||||
Ok(start_time.to_string())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[cfg(all(test, any(unix, windows)))]
|
||||
#[path = "pid_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[cfg(windows)]
|
||||
use super::windows::try_lock_file;
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn read_process_start_time(pid: u32) -> Result<String> {
|
||||
super::windows::Process::open(pid)?
|
||||
.context("daemon process exited during startup")?
|
||||
.start_time()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
async fn process_matches_record(record: &PidRecord) -> Result<bool> {
|
||||
let process = match super::windows::Process::open(record.pid) {
|
||||
Ok(process) => process,
|
||||
// A managed daemon is queryable by its launching user. A stale PID may
|
||||
// have been reused by a protected process; never try to terminate it.
|
||||
Err(err)
|
||||
if err.downcast_ref::<std::io::Error>().is_some_and(|err| {
|
||||
err.raw_os_error()
|
||||
== Some(windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED as i32)
|
||||
}) =>
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let Some(process) = process else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(process.is_running()? && process.start_time()? == record.process_start_time)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn force_terminate_process(pid: u32) -> Result<()> {
|
||||
if let Some(process) = super::windows::Process::open(pid)? {
|
||||
process.terminate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
use force_terminate_process as terminate_process;
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[path = "pid_start.rs"]
|
||||
mod start;
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Detached Unix process launch and PID publication under the reservation lock.
|
||||
//! Detached process launch and PID publication. Hold the reservation lock until
|
||||
//! the record is published.
|
||||
|
||||
use super::PidBackend;
|
||||
use super::PidFileState;
|
||||
@@ -11,21 +12,24 @@ use tokio::fs;
|
||||
use tokio::process::Command;
|
||||
|
||||
impl PidBackend {
|
||||
pub(crate) async fn start(&self) -> Result<Option<u32>> {
|
||||
pub(super) async fn start_inner(&self) -> Result<Option<u32>> {
|
||||
if let Some(parent) = self.pid_file.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
codex_uds::prepare_private_socket_directory(parent)
|
||||
.await
|
||||
.with_context(|| format!("failed to create pid directory {}", parent.display()))?;
|
||||
}
|
||||
let reservation_lock = self.acquire_reservation_lock().await?;
|
||||
let _pid_file = loop {
|
||||
loop {
|
||||
match fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.pid_file)
|
||||
.await
|
||||
{
|
||||
Ok(pid_file) => break pid_file,
|
||||
Ok(pid_file) => {
|
||||
drop(pid_file);
|
||||
break;
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
match self.read_pid_file_state_with_lock_held().await? {
|
||||
PidFileState::Missing => continue,
|
||||
@@ -47,8 +51,15 @@ impl PidBackend {
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut command = Command::new(&self.codex_bin);
|
||||
}
|
||||
// Pin the Windows image path across installer junction retargeting.
|
||||
#[cfg(windows)]
|
||||
let codex_bin = fs::canonicalize(&self.codex_bin)
|
||||
.await
|
||||
.unwrap_or_else(|_| self.codex_bin.clone());
|
||||
#[cfg(not(windows))]
|
||||
let codex_bin = &self.codex_bin;
|
||||
let mut command = Command::new(codex_bin);
|
||||
let stderr_log = match self.open_stderr_log().await {
|
||||
Ok(stderr_log) => stderr_log,
|
||||
Err(err) => {
|
||||
@@ -77,13 +88,37 @@ impl PidBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows_sys::Win32::System::Threading::CREATE_BREAKAWAY_FROM_JOB;
|
||||
use windows_sys::Win32::System::Threading::DETACHED_PROCESS;
|
||||
// Never retry inside the parent's Job Object: that would report a
|
||||
// successful launch that dies when the terminal/SSH session closes.
|
||||
command.creation_flags(DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB);
|
||||
let shutdown_file = self.pid_file.with_extension("shutdown");
|
||||
match fs::remove_file(&shutdown_file).await {
|
||||
Ok(()) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => return Err(err).context("failed to clear daemon shutdown request"),
|
||||
}
|
||||
command.env(
|
||||
codex_app_server_transport::DAEMON_SHUTDOWN_FILE_ENV,
|
||||
shutdown_file,
|
||||
);
|
||||
}
|
||||
|
||||
let child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
let _ = fs::remove_file(&self.pid_file).await;
|
||||
return Err(err).with_context(|| {
|
||||
let job_hint = if cfg!(windows) {
|
||||
" (the Windows host job must allow breakaway)"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"failed to spawn detached app-server process using {}",
|
||||
"failed to spawn detached app-server process using {}{job_hint}",
|
||||
self.codex_bin.display()
|
||||
)
|
||||
});
|
||||
@@ -92,7 +127,15 @@ impl PidBackend {
|
||||
let pid = child
|
||||
.id()
|
||||
.context("spawned app-server process has no pid")?;
|
||||
let record = match read_process_start_time(pid).await {
|
||||
let record = match async {
|
||||
#[cfg(windows)]
|
||||
super::super::windows::Process::open(pid)?
|
||||
.context("daemon exited during launch")?
|
||||
.ensure_detached()?;
|
||||
read_process_start_time(pid).await
|
||||
}
|
||||
.await
|
||||
{
|
||||
Ok(process_start_time) => PidRecord {
|
||||
pid,
|
||||
process_start_time,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#[cfg(unix)]
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -11,6 +12,7 @@ use super::PidCommandKind;
|
||||
use super::PidFileState;
|
||||
use super::PidLogTail;
|
||||
use super::PidRecord;
|
||||
#[cfg(unix)]
|
||||
use super::read_process_start_time;
|
||||
use super::read_stderr_log_tail;
|
||||
use super::stderr_log_file_for_pid_file;
|
||||
@@ -99,7 +101,11 @@ async fn stop_waits_for_live_reservation_to_resolve() {
|
||||
#[tokio::test]
|
||||
async fn start_retries_stale_empty_pid_file_under_its_own_lock() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let pid_file = temp_dir.path().join("app-server.pid");
|
||||
let state_dir = temp_dir.path().join("state");
|
||||
codex_uds::prepare_private_socket_directory(&state_dir)
|
||||
.await
|
||||
.expect("private state directory");
|
||||
let pid_file = state_dir.join("app-server.pid");
|
||||
tokio::fs::write(&pid_file, "")
|
||||
.await
|
||||
.expect("write pid file");
|
||||
@@ -149,6 +155,7 @@ async fn stale_record_cleanup_preserves_replacement_record() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stop_reaps_untracked_app_server_child() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
@@ -255,3 +262,68 @@ async fn read_stderr_log_tail_returns_recent_complete_lines() {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn stale_creation_time_never_stops_reused_pid() {
|
||||
let temp = TempDir::new().expect("temp");
|
||||
let backend = PidBackend::new(
|
||||
temp.path().join("codex.exe"),
|
||||
temp.path().join("server.pid"),
|
||||
/*remote_control_enabled*/ false,
|
||||
);
|
||||
let record = PidRecord {
|
||||
pid: std::process::id(),
|
||||
process_start_time: "stale".into(),
|
||||
};
|
||||
tokio::fs::write(&backend.pid_file, serde_json::to_vec(&record).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
backend.stop().await.expect("stale record cleanup");
|
||||
assert!(!backend.pid_file.exists());
|
||||
assert!(!backend.pid_file.with_extension("shutdown").exists());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn inaccessible_reused_pid_is_stale_without_hiding_process_open_errors() {
|
||||
use futures::FutureExt;
|
||||
use windows_sys::Win32::Security::ImpersonateAnonymousToken;
|
||||
use windows_sys::Win32::Security::RevertToSelf;
|
||||
use windows_sys::Win32::System::Threading::GetCurrentThread;
|
||||
|
||||
// Isolate impersonation from Tokio workers and other tests. Query our own
|
||||
// process anonymously instead of assuming a system PID is inaccessible.
|
||||
std::thread::spawn(|| {
|
||||
let record = PidRecord {
|
||||
pid: std::process::id(),
|
||||
process_start_time: "stale".into(),
|
||||
};
|
||||
assert!(
|
||||
crate::backend::windows::Process::open(record.pid)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert_ne!(unsafe { ImpersonateAnonymousToken(GetCurrentThread()) }, 0);
|
||||
let opened = crate::backend::windows::Process::open(record.pid);
|
||||
// Windows identity checks are synchronous; never suspend while impersonating.
|
||||
let matches = super::process_matches_record(&record).now_or_never();
|
||||
let reverted = unsafe { RevertToSelf() };
|
||||
assert_ne!(reverted, 0);
|
||||
|
||||
let error = match opened {
|
||||
Err(error) => error,
|
||||
Ok(_) => panic!("anonymous caller unexpectedly queried the test process"),
|
||||
};
|
||||
assert_eq!(
|
||||
error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.unwrap()
|
||||
.raw_os_error(),
|
||||
Some(windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED as i32),
|
||||
);
|
||||
assert!(!matches.expect("identity check must not suspend").unwrap());
|
||||
})
|
||||
.join()
|
||||
.expect("anonymous identity check");
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
//! 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::IsProcessInJob;
|
||||
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")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "windows_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,47 @@
|
||||
use super::Process;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED;
|
||||
use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
|
||||
use windows_sys::Win32::System::Threading::TerminateProcess;
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_queries_do_not_require_termination_access() {
|
||||
let mut child = tokio::process::Command::new("powershell.exe")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
"Start-Sleep 60",
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.expect("child");
|
||||
let process = Process::open(child.id().expect("pid"))
|
||||
.expect("query handle")
|
||||
.expect("live process");
|
||||
assert!(!process.start_time().expect("creation time").is_empty());
|
||||
assert!(process.is_running().expect("liveness"));
|
||||
// Check the rights on the actual query handle, independent of privileges
|
||||
// that could let the caller reopen the process with termination access.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
TerminateProcess(process.0.as_raw_handle() as _, /*uexitcode*/ 1)
|
||||
},
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
std::io::Error::last_os_error().raw_os_error(),
|
||||
Some(ERROR_ACCESS_DENIED as i32)
|
||||
);
|
||||
assert!(
|
||||
process
|
||||
.is_running()
|
||||
.expect("query must not terminate child")
|
||||
);
|
||||
child
|
||||
.kill()
|
||||
.await
|
||||
.expect("cleanup through original spawn handle");
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
//! Managed app-server lifecycle, serialized across CLI invocations and the updater.
|
||||
|
||||
mod backend;
|
||||
#[cfg(windows)]
|
||||
use backend::windows::try_lock_file;
|
||||
mod client;
|
||||
mod managed_install;
|
||||
mod remote_control_client;
|
||||
@@ -727,12 +731,18 @@ impl Daemon {
|
||||
|
||||
async fn open_operation_lock_file(&self) -> Result<tokio::fs::File> {
|
||||
if let Some(parent) = self.operation_lock_file.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.with_context(|| {
|
||||
format!(
|
||||
"failed to create daemon state directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
#[cfg(unix)]
|
||||
if let Some(home) = parent.parent() {
|
||||
tokio::fs::create_dir_all(home).await?;
|
||||
}
|
||||
codex_uds::prepare_private_socket_directory(parent)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to create daemon state directory {}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
@@ -842,7 +852,7 @@ fn try_lock_file(file: &tokio::fs::File) -> Result<bool> {
|
||||
Err(err).context("failed to lock daemon operation")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn try_lock_file(_file: &tokio::fs::File) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
@@ -1006,6 +1016,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stop_creates_missing_home_parent() {
|
||||
let temp = TempDir::new().expect("temp dir");
|
||||
let state = temp.path().join("missing-home").join("daemon-state");
|
||||
let daemon = Daemon {
|
||||
socket_path: state.join("server.sock"),
|
||||
pid_file: state.join("server.pid"),
|
||||
update_pid_file: state.join("updater.pid"),
|
||||
operation_lock_file: state.join("daemon.lock"),
|
||||
settings_file: state.join("settings.json"),
|
||||
managed_codex_bin: state.join("missing-codex"),
|
||||
};
|
||||
assert_eq!(
|
||||
daemon
|
||||
.run(super::LifecycleCommand::Stop)
|
||||
.await
|
||||
.expect("stop on fresh home")
|
||||
.status,
|
||||
LifecycleStatus::NotRunning,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_ready_context_reports_daemon_app_server_before_stderr() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
|
||||
Reference in new issue
Block a user