mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(sleep-inhibitor): add Linux and Windows idle-sleep prevention (#11766)
## 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.
This commit is contained in:
committed by
GitHub
Unverified
parent
0b6c2e5652
commit
67d9261e2c
Generated
+2
@@ -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]]
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<LinuxBackend>,
|
||||
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<Child, std::io::Error> {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
@@ -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<PowerRequest>,
|
||||
}
|
||||
|
||||
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<Self, String> {
|
||||
let mut wide_reason: Vec<u16> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user