diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 02103c6c49..94906b3cc9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2168,6 +2168,7 @@ dependencies = [ "tempfile", "tokio", "tokio-tungstenite", + "windows-sys 0.52.0", ] [[package]] diff --git a/codex-rs/app-server-daemon/Cargo.toml b/codex-rs/app-server-daemon/Cargo.toml index fee5732197..171379539f 100644 --- a/codex-rs/app-server-daemon/Cargo.toml +++ b/codex-rs/app-server-daemon/Cargo.toml @@ -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"] } diff --git a/codex-rs/app-server-daemon/src/backend/mod.rs b/codex-rs/app-server-daemon/src/backend/mod.rs index d40df07a0e..faa99a5a17 100644 --- a/codex-rs/app-server-daemon/src/backend/mod.rs +++ b/codex-rs/app-server-daemon/src/backend/mod.rs @@ -1,4 +1,6 @@ mod pid; +#[cfg(windows)] +pub(crate) mod windows; use std::path::Path; use std::path::PathBuf; diff --git a/codex-rs/app-server-daemon/src/backend/pid.rs b/codex-rs/app-server-daemon/src/backend/pid.rs index 77de413a3b..2deb9f37b8 100644 --- a/codex-rs/app-server-daemon/src/backend/pid.rs +++ b/codex-rs/app-server-daemon/src/backend/pid.rs @@ -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> { + self.start_inner().await + } + + #[cfg(not(any(unix, windows)))] pub(crate) async fn start(&self) -> Result> { 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 { 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 { } } -#[cfg(not(unix))] +#[cfg(not(any(unix, windows)))] async fn process_matches_record(_record: &PidRecord) -> Result { 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 { Err(err).context("failed to lock pid reservation") } -#[cfg(not(unix))] +#[cfg(not(any(unix, windows)))] fn try_lock_file(_file: &fs::File) -> Result { 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 { let file = match fs::OpenOptions::new() .write(true) @@ -524,12 +547,12 @@ async fn reservation_lock_is_active(path: &Path) -> Result { Ok(!try_lock_file(&file)?) } -#[cfg(not(unix))] +#[cfg(not(any(unix, windows)))] async fn reservation_lock_is_active(_path: &Path) -> Result { 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 { 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 { + super::windows::Process::open(pid)? + .context("daemon process exited during startup")? + .start_time() +} + +#[cfg(windows)] +async fn process_matches_record(record: &PidRecord) -> Result { + 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::().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; diff --git a/codex-rs/app-server-daemon/src/backend/pid_start.rs b/codex-rs/app-server-daemon/src/backend/pid_start.rs index d0df052e7c..c007a1c4c2 100644 --- a/codex-rs/app-server-daemon/src/backend/pid_start.rs +++ b/codex-rs/app-server-daemon/src/backend/pid_start.rs @@ -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> { + pub(super) async fn start_inner(&self) -> Result> { 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, diff --git a/codex-rs/app-server-daemon/src/backend/pid_tests.rs b/codex-rs/app-server-daemon/src/backend/pid_tests.rs index de67fa4e3d..46cc07d808 100644 --- a/codex-rs/app-server-daemon/src/backend/pid_tests.rs +++ b/codex-rs/app-server-daemon/src/backend/pid_tests.rs @@ -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::() + .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"); +} diff --git a/codex-rs/app-server-daemon/src/backend/windows.rs b/codex-rs/app-server-daemon/src/backend/windows.rs new file mode 100644 index 0000000000..178d8c5f58 --- /dev/null +++ b/codex-rs/app-server-daemon/src/backend/windows.rs @@ -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> { + Self::open_with_access(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE) + } + + fn open_with_access(pid: u32, access: PROCESS_ACCESS_RIGHTS) -> Result> { + 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 { + 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 { + 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 { + 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; diff --git a/codex-rs/app-server-daemon/src/backend/windows_tests.rs b/codex-rs/app-server-daemon/src/backend/windows_tests.rs new file mode 100644 index 0000000000..840ea887d1 --- /dev/null +++ b/codex-rs/app-server-daemon/src/backend/windows_tests.rs @@ -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"); +} diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index c90978803d..0bd4a97408 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -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 { 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 { 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 { 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");