diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 224502c38a..b748131568 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2502,8 +2502,10 @@ name = "codex-utils-sleep-inhibitor" version = "0.0.0" dependencies = [ "core-foundation 0.9.4", + "dbus", "libc", "tracing", + "windows-sys 0.60.2", ] [[package]] diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 33801c47ce..9699aee8fa 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -609,7 +609,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 703433d819..b0048e14db 100644 --- a/codex-rs/utils/sleep-inhibitor/Cargo.toml +++ b/codex-rs/utils/sleep-inhibitor/Cargo.toml @@ -11,3 +11,16 @@ workspace = true core-foundation = "0.9" libc = { workspace = true } tracing = { workspace = true } + +[target.'cfg(target_os = "linux")'.dependencies] +dbus = "0.9" +tracing = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] +tracing = { workspace = true } +windows-sys = { version = "0.60.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 0f2f32df64..14067acc84 100644 --- a/codex-rs/utils/sleep-inhibitor/src/lib.rs +++ b/codex-rs/utils/sleep-inhibitor/src/lib.rs @@ -3,10 +3,14 @@ //! On macOS this uses native IOKit power assertions instead of spawning //! `caffeinate`, so assertion lifecycle is tied directly to Rust object lifetime. -#[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_inhibitor; +#[cfg(target_os = "windows")] +mod windows_inhibitor; use std::fmt::Debug; @@ -27,7 +31,13 @@ impl SleepInhibitor { #[cfg(target_os = "macos")] let platform: Box = Box::new(macos_inhibitor::MacOsSleepInhibitor::new()); - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "linux")] + let platform: Box = + Box::new(linux_inhibitor::LinuxSleepInhibitor::new()); + #[cfg(target_os = "windows")] + let platform: Box = + Box::new(windows_inhibitor::WindowsSleepInhibitor::new()); + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] let platform: Box = Box::new(dummy::DummySleepInhibitor::new()); Self { enabled, platform } 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 0000000000..9f5b07dadb --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/linux_inhibitor.rs @@ -0,0 +1,166 @@ +use crate::PlatformSleepInhibitor; +use dbus::arg::OwnedFd; +use dbus::blocking::Connection; +use std::time::Duration; +use tracing::warn; + +const ASSERTION_REASON: &str = "Codex is running an active turn"; +const APP_ID: &str = "codex"; +const DBUS_TIMEOUT: Duration = Duration::from_secs(2); +const GNOME_INHIBIT_SUSPEND_SESSION_FLAG: u32 = 4; + +#[derive(Debug, Default)] +pub(crate) struct LinuxSleepInhibitor { + state: InhibitState, +} + +#[derive(Debug, Default)] +enum InhibitState { + #[default] + Inactive, + Logind(OwnedFd), + Cookie { + api: CookieApi, + cookie: u32, + }, +} + +#[derive(Debug, Clone, Copy)] +enum CookieApi { + Gnome, + FreedesktopPower, + FreedesktopScreensaver, +} + +impl CookieApi { + fn service_name(self) -> &'static str { + match self { + Self::Gnome => "org.gnome.SessionManager", + Self::FreedesktopPower => "org.freedesktop.PowerManagement", + Self::FreedesktopScreensaver => "org.freedesktop.ScreenSaver", + } + } + + fn object_path(self) -> &'static str { + match self { + Self::Gnome => "/org/gnome/SessionManager", + Self::FreedesktopPower => "/org/freedesktop/PowerManagement/Inhibit", + Self::FreedesktopScreensaver => "/org/freedesktop/ScreenSaver", + } + } + + fn interface_name(self) -> &'static str { + match self { + Self::Gnome => "org.gnome.SessionManager", + Self::FreedesktopPower => "org.freedesktop.PowerManagement.Inhibit", + Self::FreedesktopScreensaver => "org.freedesktop.ScreenSaver", + } + } + + fn uninhibit_method(self) -> &'static str { + match self { + Self::Gnome => "Uninhibit", + Self::FreedesktopPower | Self::FreedesktopScreensaver => "UnInhibit", + } + } +} + +impl LinuxSleepInhibitor { + pub(crate) fn new() -> Self { + Self::default() + } +} + +impl PlatformSleepInhibitor for LinuxSleepInhibitor { + fn acquire(&mut self) { + if !matches!(self.state, InhibitState::Inactive) { + return; + } + + match acquire_logind_inhibitor() { + Ok(state) => { + self.state = state; + return; + } + Err(error) => { + warn!( + error = %error, + "Failed to acquire sleep inhibitor via org.freedesktop.login1" + ); + } + } + + for api in [ + CookieApi::Gnome, + CookieApi::FreedesktopPower, + CookieApi::FreedesktopScreensaver, + ] { + match acquire_cookie_inhibitor(api) { + Ok(state) => { + self.state = state; + return; + } + Err(error) => { + warn!(?api, error = %error, "Failed to acquire sleep inhibitor via D-Bus"); + } + } + } + + warn!("No Linux sleep inhibition API is available"); + } + + fn release(&mut self) { + match std::mem::take(&mut self.state) { + InhibitState::Inactive => {} + InhibitState::Logind(fd) => drop(fd), + InhibitState::Cookie { api, cookie } => { + if let Err(error) = release_cookie_inhibitor(api, cookie) { + warn!(?api, error = %error, "Failed to release D-Bus sleep inhibitor"); + } + } + } + } +} + +fn acquire_logind_inhibitor() -> Result { + let connection = Connection::new_system()?; + let proxy = connection.with_proxy( + "org.freedesktop.login1", + "/org/freedesktop/login1", + DBUS_TIMEOUT, + ); + let (fd,): (OwnedFd,) = proxy.method_call( + "org.freedesktop.login1.Manager", + "Inhibit", + ("sleep", APP_ID, ASSERTION_REASON, "block"), + )?; + Ok(InhibitState::Logind(fd)) +} + +fn acquire_cookie_inhibitor(api: CookieApi) -> Result { + let connection = Connection::new_session()?; + let proxy = connection.with_proxy(api.service_name(), api.object_path(), DBUS_TIMEOUT); + let (cookie,): (u32,) = match api { + CookieApi::Gnome => proxy.method_call( + api.interface_name(), + "Inhibit", + ( + APP_ID, + 0_u32, + ASSERTION_REASON, + GNOME_INHIBIT_SUSPEND_SESSION_FLAG, + ), + )?, + CookieApi::FreedesktopPower | CookieApi::FreedesktopScreensaver => { + proxy.method_call(api.interface_name(), "Inhibit", (APP_ID, ASSERTION_REASON))? + } + }; + Ok(InhibitState::Cookie { api, cookie }) +} + +fn release_cookie_inhibitor(api: CookieApi, cookie: u32) -> Result<(), dbus::Error> { + let connection = Connection::new_session()?; + let proxy = connection.with_proxy(api.service_name(), api.object_path(), DBUS_TIMEOUT); + let _: () = proxy.method_call(api.interface_name(), api.uninhibit_method(), (cookie,))?; + Ok(()) +} 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 0000000000..a0142c4a33 --- /dev/null +++ b/codex-rs/utils/sleep-inhibitor/src/windows_inhibitor.rs @@ -0,0 +1,93 @@ +use crate::PlatformSleepInhibitor; +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::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::PowerRequestExecutionRequired; +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, +} + +impl WindowsSleepInhibitor { + pub(crate) fn new() -> Self { + Self::default() + } +} + +impl PlatformSleepInhibitor for WindowsSleepInhibitor { + fn acquire(&mut self) { + if self.request.is_some() { + return; + } + + match PowerRequest::new_execution_required(ASSERTION_REASON) { + Ok(request) => { + self.request = Some(request); + } + Err(error) => { + warn!( + reason = error, + "Failed to acquire Windows sleep-prevention request" + ); + } + } + } + + 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_execution_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(), + }, + }; + let handle = unsafe { PowerCreateRequest(&context) }; + if handle.is_null() { + return Err("PowerCreateRequest failed"); + } + + let request_type = PowerRequestExecutionRequired; + if unsafe { PowerSetRequest(handle, request_type) } == 0 { + let _ = unsafe { CloseHandle(handle) }; + return Err("PowerSetRequest failed"); + } + + Ok(Self { + handle, + request_type, + }) + } +} + +impl Drop for PowerRequest { + fn drop(&mut self) { + let _ = unsafe { PowerClearRequest(self.handle, self.request_type) }; + let _ = unsafe { CloseHandle(self.handle) }; + } +}