diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4f815078c..ca09a75fa 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2531,7 +2531,9 @@ name = "codex-utils-sleep-inhibitor" version = "0.0.0" dependencies = [ "core-foundation 0.9.4", + "libc", "tracing", + "windows-sys 0.61.2", ] [[package]] diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index f5d87f7a9..990aa95fa 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -654,7 +654,11 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::PreventIdleSleep, key: "prevent_idle_sleep", - stage: if cfg!(target_os = "macos") { + stage: if cfg!(any( + target_os = "macos", + target_os = "linux", + target_os = "windows" + )) { Stage::Experimental { name: "Prevent sleep while running", menu_description: "Keep your computer awake while Codex is running a thread.", diff --git a/codex-rs/utils/sleep-inhibitor/Cargo.toml b/codex-rs/utils/sleep-inhibitor/Cargo.toml index f691b191e..888705a77 100644 --- a/codex-rs/utils/sleep-inhibitor/Cargo.toml +++ b/codex-rs/utils/sleep-inhibitor/Cargo.toml @@ -7,6 +7,19 @@ license.workspace = true [lints] workspace = true +[dependencies] +tracing = { workspace = true } + [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" -tracing = { workspace = true } + +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.61.2", features = [ + "Win32_Foundation", + "Win32_System_Power", + "Win32_System_SystemServices", + "Win32_System_Threading", +] } diff --git a/codex-rs/utils/sleep-inhibitor/src/lib.rs b/codex-rs/utils/sleep-inhibitor/src/lib.rs index 810fb170a..8c9c5a9cc 100644 --- a/codex-rs/utils/sleep-inhibitor/src/lib.rs +++ b/codex-rs/utils/sleep-inhibitor/src/lib.rs @@ -1,17 +1,29 @@ //! Cross-platform helper for preventing idle sleep while a turn is running. //! -//! On macOS this uses native IOKit power assertions instead of spawning -//! `caffeinate`, so assertion lifecycle is tied directly to Rust object lifetime. +//! Platform-specific behavior: +//! - macOS: Uses native IOKit power assertions instead of spawning `caffeinate`. +//! - Linux: Spawns `systemd-inhibit` or `gnome-session-inhibit` while active. +//! - Windows: Uses `PowerCreateRequest` + `PowerSetRequest` with +//! `PowerRequestSystemRequired`. +//! - Other platforms: No-op backend. -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] mod dummy; +#[cfg(target_os = "linux")] +mod linux_inhibitor; #[cfg(target_os = "macos")] mod macos; +#[cfg(target_os = "windows")] +mod windows_inhibitor; -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] use dummy as imp; +#[cfg(target_os = "linux")] +use linux_inhibitor as imp; #[cfg(target_os = "macos")] use macos as imp; +#[cfg(target_os = "windows")] +use windows_inhibitor as imp; /// Keeps the machine awake while a turn is in progress when enabled. #[derive(Debug)] diff --git a/codex-rs/utils/sleep-inhibitor/src/linux_inhibitor.rs b/codex-rs/utils/sleep-inhibitor/src/linux_inhibitor.rs new file mode 100644 index 000000000..e97d42b22 --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/linux_inhibitor.rs @@ -0,0 +1,240 @@ +use std::os::unix::process::CommandExt; +use std::process::Child; +use std::process::Command; +use std::process::Stdio; +use tracing::warn; + +const ASSERTION_REASON: &str = "Codex is running an active turn"; +const APP_ID: &str = "codex"; +// Keep the blocker process alive "long enough" without needing restarts. +// This is `i32::MAX` seconds, which is accepted by common `sleep` implementations. +const BLOCKER_SLEEP_SECONDS: &str = "2147483647"; + +#[derive(Debug, Default)] +pub(crate) struct LinuxSleepInhibitor { + state: InhibitState, + preferred_backend: Option, + missing_backend_logged: bool, +} + +pub(crate) use LinuxSleepInhibitor as SleepInhibitor; + +#[derive(Debug, Default)] +enum InhibitState { + #[default] + Inactive, + Active { + backend: LinuxBackend, + child: Child, + }, +} + +#[derive(Debug, Clone, Copy)] +enum LinuxBackend { + SystemdInhibit, + GnomeSessionInhibit, +} + +impl LinuxSleepInhibitor { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn acquire(&mut self) { + if let InhibitState::Active { backend, child } = &mut self.state { + match child.try_wait() { + Ok(None) => return, + Ok(Some(status)) => { + warn!( + ?backend, + ?status, + "Linux sleep inhibitor backend exited unexpectedly; attempting fallback" + ); + } + Err(error) => { + warn!( + ?backend, + reason = %error, + "Failed to query Linux sleep inhibitor backend status; attempting restart" + ); + } + } + } + + self.state = InhibitState::Inactive; + let should_log_backend_failures = !self.missing_backend_logged; + let backends = match self.preferred_backend { + Some(LinuxBackend::SystemdInhibit) => [ + LinuxBackend::SystemdInhibit, + LinuxBackend::GnomeSessionInhibit, + ], + Some(LinuxBackend::GnomeSessionInhibit) => [ + LinuxBackend::GnomeSessionInhibit, + LinuxBackend::SystemdInhibit, + ], + None => [ + LinuxBackend::SystemdInhibit, + LinuxBackend::GnomeSessionInhibit, + ], + }; + + for backend in backends { + match spawn_backend(backend) { + Ok(mut child) => match child.try_wait() { + Ok(None) => { + self.state = InhibitState::Active { backend, child }; + self.preferred_backend = Some(backend); + self.missing_backend_logged = false; + return; + } + Ok(Some(status)) => { + if should_log_backend_failures { + warn!( + ?backend, + ?status, + "Linux sleep inhibitor backend exited immediately" + ); + } + } + Err(error) => { + if should_log_backend_failures { + warn!( + ?backend, + reason = %error, + "Failed to query Linux sleep inhibitor backend status after spawn" + ); + } + if let Err(kill_error) = child.kill() + && !child_exited(&kill_error) + { + warn!( + ?backend, + reason = %kill_error, + "Failed to stop Linux sleep inhibitor backend after status probe failure" + ); + } + if let Err(wait_error) = child.wait() + && !child_exited(&wait_error) + { + warn!( + ?backend, + reason = %wait_error, + "Failed to reap Linux sleep inhibitor backend after status probe failure" + ); + } + } + }, + Err(error) => { + if should_log_backend_failures && error.kind() != std::io::ErrorKind::NotFound { + warn!( + ?backend, + reason = %error, + "Failed to start Linux sleep inhibitor backend" + ); + } + } + } + } + + if should_log_backend_failures { + warn!("No Linux sleep inhibitor backend is available"); + self.missing_backend_logged = true; + } + } + + pub(crate) fn release(&mut self) { + match std::mem::take(&mut self.state) { + InhibitState::Inactive => {} + InhibitState::Active { backend, mut child } => { + if let Err(error) = child.kill() + && !child_exited(&error) + { + warn!(?backend, reason = %error, "Failed to stop Linux sleep inhibitor backend"); + } + if let Err(error) = child.wait() + && !child_exited(&error) + { + warn!(?backend, reason = %error, "Failed to reap Linux sleep inhibitor backend"); + } + } + } + } +} + +impl Drop for LinuxSleepInhibitor { + fn drop(&mut self) { + self.release(); + } +} + +fn spawn_backend(backend: LinuxBackend) -> Result { + // Ensure the helper receives SIGTERM when the original parent dies. + // `parent_pid` is captured before spawn and checked in `pre_exec` to avoid + // the fork/exec race where the parent exits before PDEATHSIG is armed. + // SAFETY: `getpid` has no preconditions and is safe to call here. + let parent_pid = unsafe { libc::getpid() }; + let mut command = match backend { + LinuxBackend::SystemdInhibit => { + let mut command = Command::new("systemd-inhibit"); + command.args([ + "--what=idle", + "--mode=block", + "--who", + APP_ID, + "--why", + ASSERTION_REASON, + "--", + "sleep", + BLOCKER_SLEEP_SECONDS, + ]); + command + } + LinuxBackend::GnomeSessionInhibit => { + let mut command = Command::new("gnome-session-inhibit"); + command.args([ + "--inhibit", + "idle", + "--reason", + ASSERTION_REASON, + "sleep", + BLOCKER_SLEEP_SECONDS, + ]); + command + } + }; + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + // SAFETY: `pre_exec` must be registered before spawn. The closure only + // performs libc setup for the child process and returns an `io::Error` + // when parent-death signal setup fails. + unsafe { + command.pre_exec(move || { + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 { + return Err(std::io::Error::last_os_error()); + } + if libc::getppid() != parent_pid { + libc::raise(libc::SIGTERM); + } + Ok(()) + }); + } + + command.spawn() +} + +fn child_exited(error: &std::io::Error) -> bool { + matches!(error.kind(), std::io::ErrorKind::InvalidInput) +} + +#[cfg(test)] +mod tests { + use super::BLOCKER_SLEEP_SECONDS; + + #[test] + fn sleep_seconds_is_i32_max() { + assert_eq!(BLOCKER_SLEEP_SECONDS, format!("{}", i32::MAX)); + } +} diff --git a/codex-rs/utils/sleep-inhibitor/src/windows_inhibitor.rs b/codex-rs/utils/sleep-inhibitor/src/windows_inhibitor.rs new file mode 100644 index 000000000..2cf039534 --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/windows_inhibitor.rs @@ -0,0 +1,119 @@ +use std::ffi::OsStr; +use std::iter::once; +use std::os::windows::ffi::OsStrExt; +use tracing::warn; +use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::System::Power::POWER_REQUEST_TYPE; +use windows_sys::Win32::System::Power::PowerClearRequest; +use windows_sys::Win32::System::Power::PowerCreateRequest; +use windows_sys::Win32::System::Power::PowerRequestSystemRequired; +use windows_sys::Win32::System::Power::PowerSetRequest; +use windows_sys::Win32::System::SystemServices::POWER_REQUEST_CONTEXT_VERSION; +use windows_sys::Win32::System::Threading::POWER_REQUEST_CONTEXT_SIMPLE_STRING; +use windows_sys::Win32::System::Threading::REASON_CONTEXT; +use windows_sys::Win32::System::Threading::REASON_CONTEXT_0; + +const ASSERTION_REASON: &str = "Codex is running an active turn"; + +#[derive(Debug, Default)] +pub(crate) struct WindowsSleepInhibitor { + request: Option, +} + +pub(crate) use WindowsSleepInhibitor as SleepInhibitor; + +impl WindowsSleepInhibitor { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn acquire(&mut self) { + if self.request.is_some() { + return; + } + + match PowerRequest::new_system_required(ASSERTION_REASON) { + Ok(request) => { + self.request = Some(request); + } + Err(error) => { + warn!( + reason = %error, + "Failed to acquire Windows sleep-prevention request" + ); + } + } + } + + pub(crate) fn release(&mut self) { + self.request = None; + } +} + +#[derive(Debug)] +struct PowerRequest { + handle: windows_sys::Win32::Foundation::HANDLE, + request_type: POWER_REQUEST_TYPE, +} + +impl PowerRequest { + fn new_system_required(reason: &str) -> Result { + let mut wide_reason: Vec = OsStr::new(reason).encode_wide().chain(once(0)).collect(); + let context = REASON_CONTEXT { + Version: POWER_REQUEST_CONTEXT_VERSION, + Flags: POWER_REQUEST_CONTEXT_SIMPLE_STRING, + Reason: REASON_CONTEXT_0 { + SimpleReasonString: wide_reason.as_mut_ptr(), + }, + }; + // SAFETY: `context` points to a valid `REASON_CONTEXT` for the duration + // of the call and Windows copies the relevant data before returning. + let handle = unsafe { PowerCreateRequest(&context) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + let error = std::io::Error::last_os_error(); + return Err(format!("PowerCreateRequest failed: {error}")); + } + + // Match macOS `PreventUserIdleSystemSleep`: prevent idle system sleep + // without forcing the display to stay on. + let request_type = PowerRequestSystemRequired; + // SAFETY: `handle` is a live power request handle and `request_type` is a + // valid power request enum value. + if unsafe { PowerSetRequest(handle, request_type) } == 0 { + let error = std::io::Error::last_os_error(); + // SAFETY: `handle` was returned by `PowerCreateRequest` and has not + // been closed yet on this error path. + let _ = unsafe { CloseHandle(handle) }; + return Err(format!("PowerSetRequest failed: {error}")); + } + + Ok(Self { + handle, + request_type, + }) + } +} + +impl Drop for PowerRequest { + fn drop(&mut self) { + // SAFETY: `self.handle` is the handle owned by this `PowerRequest`, and + // `self.request_type` is the request type that was set on acquire. + if unsafe { PowerClearRequest(self.handle, self.request_type) } == 0 { + let error = std::io::Error::last_os_error(); + warn!( + reason = %error, + "Failed to clear Windows sleep-prevention request" + ); + } + // SAFETY: `self.handle` is owned by this struct and closed exactly once + // in `Drop`. + if unsafe { CloseHandle(self.handle) } == 0 { + let error = std::io::Error::last_os_error(); + warn!( + reason = %error, + "Failed to close Windows sleep-prevention request handle" + ); + } + } +}