From 67d9261e2c05adc175cac6adf1975dcfdc81755d Mon Sep 17 00:00:00 2001 From: Yaroslav Volovich Date: Tue, 24 Feb 2026 19:51:44 +0000 Subject: [PATCH] feat(sleep-inhibitor): add Linux and Windows idle-sleep prevention (#11766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Background - follow-up to previous macOS-only PR: https://github.com/openai/codex/pull/11711 - follow-up macOS refactor PR (current structural approach used here): https://github.com/openai/codex/pull/12340 ## Summary - extend `codex-utils-sleep-inhibitor` with Linux and Windows backends while preserving existing macOS behavior - Linux backend: - use `systemd-inhibit` (`--what=idle --mode=block`) when available - fall back to `gnome-session-inhibit` (`--inhibit idle`) when available - keep no-op behavior if neither backend exists on host - Windows backend: - use Win32 power request handles (`PowerCreateRequest` + `PowerSetRequest` / `PowerClearRequest`) with `PowerRequestSystemRequired` - make `prevent_idle_sleep` Experimental on macOS/Linux/Windows; keep under development on other targets ## Testing - `just fmt` - `cargo test -p codex-utils-sleep-inhibitor` - `cargo test -p codex-core features::tests::` - `cargo test -p codex-tui chatwidget::tests::` - `just fix -p codex-utils-sleep-inhibitor` - `just fix -p codex-core` ## Semantics and API references - Goal remains: prevent idle system sleep while a turn is running. - Linux: - `systemd-inhibit` / login1 inhibitor model: - https://www.freedesktop.org/software/systemd/man/latest/systemd-inhibit.html - https://www.freedesktop.org/software/systemd/man/org.freedesktop.login1.html - https://systemd.io/INHIBITOR_LOCKS/ - xdg-desktop-portal Inhibit (relevant for sandboxed apps): - https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Inhibit.html - Windows: - `PowerCreateRequest`: - https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-powercreaterequest - `PowerSetRequest`: - https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-powersetrequest - `PowerClearRequest`: - https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-powerclearrequest - `SetThreadExecutionState` (alternative baseline API): - https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setthreadexecutionstate ## Chromium vs this PR - Chromium Linux backend: - https://github.com/chromium/chromium/blob/main/services/device/wake_lock/power_save_blocker/power_save_blocker_linux.cc - Chromium Windows backend: - https://github.com/chromium/chromium/blob/main/services/device/wake_lock/power_save_blocker/power_save_blocker_win.cc - Electron powerSaveBlocker entry point: - https://github.com/electron/electron/blob/main/shell/browser/api/electron_api_power_save_blocker.cc ## Why we differ from Chromium - Linux implementation mechanism: - Chromium uses in-process D-Bus APIs plus UI-integrated screen-saver suspension. - This PR uses command-based inhibitor backends (`systemd-inhibit`, `gnome-session-inhibit`) instead of linking a Linux D-Bus client in this crate. - Reason: keep `codex-utils-sleep-inhibitor` dependency-light and avoid Linux CI/toolchain fragility from new native D-Bus linkage, while preserving the same runtime intent (hold an inhibitor while a turn runs). - Linux UI integration scope: - Chromium also uses `display::Screen::SuspendScreenSaver()` in its UI stack. - Codex `codex-rs` does not have that display abstraction in this crate, so this PR scopes Linux behavior to process-level sleep inhibition only. - Windows wake-lock type breadth: - Chromium supports both display/system wake-lock types and extra display-specific handling for some pre-Win11 scenarios. - Codex’s feature is scoped to turn execution continuity (not forcing display on), so this PR uses `PowerRequestSystemRequired` only. --- codex-rs/Cargo.lock | 2 + codex-rs/core/src/features.rs | 6 +- codex-rs/utils/sleep-inhibitor/Cargo.toml | 15 +- codex-rs/utils/sleep-inhibitor/src/lib.rs | 20 +- .../sleep-inhibitor/src/linux_inhibitor.rs | 240 ++++++++++++++++++ .../sleep-inhibitor/src/windows_inhibitor.rs | 119 +++++++++ 6 files changed, 396 insertions(+), 6 deletions(-) create mode 100644 codex-rs/utils/sleep-inhibitor/src/linux_inhibitor.rs create mode 100644 codex-rs/utils/sleep-inhibitor/src/windows_inhibitor.rs 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" + ); + } + } +}