mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui): prevent macOS idle sleep while turns run (#11711)
## Summary - add a shared `codex-core` sleep inhibitor that uses native macOS IOKit assertions (`IOPMAssertionCreateWithName` / `IOPMAssertionRelease`) instead of spawning `caffeinate` - wire sleep inhibition to turn lifecycle in `tui` (`TurnStarted` enables; `TurnComplete` and abort/error finalization disable) - gate this behavior behind a `/experimental` feature toggle (`[features].prevent_idle_sleep`) instead of a dedicated `[tui]` config flag - expose the toggle in `/experimental` on macOS; keep it under development on other platforms - keep behavior no-op on non-macOS targets <img width="1326" height="577" alt="image" src="https://github.com/user-attachments/assets/73fac06b-97ae-46a2-800a-30f9516cf8a3" /> ## Testing - `cargo check -p codex-core -p codex-tui` - `cargo test -p codex-core sleep_inhibitor::tests -- --nocapture` - `cargo test -p codex-core tui_config_missing_notifications_field_defaults_to_enabled -- --nocapture` - `cargo test -p codex-core prevent_idle_sleep_is_ -- --nocapture` ## Semantics and API references - This PR targets `caffeinate -i` semantics: prevent *idle system sleep* while allowing display idle sleep. - `caffeinate -i` mapping in Apple open source (`assertionMap`): - `kIdleAssertionFlag -> kIOPMAssertionTypePreventUserIdleSystemSleep` - Source: https://github.com/apple-oss-distributions/PowerManagement/blob/PowerManagement-1846.60.12/caffeinate/caffeinate.c#L52-L54 - Apple IOKit docs for assertion types and API: - https://developer.apple.com/documentation/iokit/iopmlib_h/iopmassertiontypes - https://developer.apple.com/documentation/iokit/1557092-iopmassertioncreatewithname - https://developer.apple.com/library/archive/qa/qa1340/_index.html ## Codex Electron vs this PR (full stack path) - Codex Electron app requests sleep blocking with `powerSaveBlocker.start("prevent-app-suspension")`: - https://github.com/openai/codex/blob/main/codex/codex-vscode/electron/src/electron-message-handler.ts - Electron maps that string to Chromium wake lock type `kPreventAppSuspension`: - https://github.com/electron/electron/blob/main/shell/browser/api/electron_api_power_save_blocker.cc - Chromium macOS backend maps wake lock types to IOKit assertion constants and calls IOKit: - `kPreventAppSuspension -> kIOPMAssertionTypeNoIdleSleep` - `kPreventDisplaySleep / kPreventDisplaySleepAllowDimming -> kIOPMAssertionTypeNoDisplaySleep` - https://github.com/chromium/chromium/blob/main/services/device/wake_lock/power_save_blocker/power_save_blocker_mac.cc ## Why this PR uses a different macOS constant name - This PR uses `"PreventUserIdleSystemSleep"` directly, via `IOPMAssertionCreateWithName`, in `codex-rs/core/src/sleep_inhibitor.rs`. - Apple’s IOKit header documents `kIOPMAssertionTypeNoIdleSleep` as deprecated and recommends `kIOPMAssertPreventUserIdleSystemSleep` / `kIOPMAssertionTypePreventUserIdleSystemSleep`: - https://github.com/apple-oss-distributions/IOKitUser/blob/IOKitUser-100222.60.2/pwr_mgt.subproj/IOPMLib.h#L1000-L1030 - So Chromium and this PR are using different constant names, but semantically equivalent idle-system-sleep prevention behavior. ## Future platform support The architecture is intentionally set up for multi-platform extensions: - UI code (`tui`) only calls `SleepInhibitor::set_turn_running(...)` on turn lifecycle boundaries. - Platform-specific behavior is isolated in `codex-rs/core/src/sleep_inhibitor.rs` behind `cfg(...)` blocks. - Feature exposure is centralized in `core/src/features.rs` and surfaced via `/experimental`. - Adding new OS backends should not require additional TUI wiring; only the backend internals and feature stage metadata need to change. Potential follow-up implementations: - Windows: - Add a backend using Win32 power APIs (`SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` as baseline). - Optionally move to `PowerCreateRequest` / `PowerSetRequest` / `PowerClearRequest` for richer assertion semantics. - Linux: - Add a backend using logind inhibitors over D-Bus (`org.freedesktop.login1.Manager.Inhibit` with `what="sleep"`). - Keep a no-op fallback where logind/D-Bus is unavailable. This PR keeps the cross-platform API surface minimal so future PRs can add Windows/Linux support incrementally with low churn. --------- Co-authored-by: jif-oai <jif@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
851fcc377b
commit
32da5eb358
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "sleep-inhibitor",
|
||||
crate_name = "codex_utils_sleep_inhibitor",
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "codex-utils-sleep-inhibitor"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.9"
|
||||
libc = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -0,0 +1,16 @@
|
||||
use crate::PlatformSleepInhibitor;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct DummySleepInhibitor;
|
||||
|
||||
impl DummySleepInhibitor {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformSleepInhibitor for DummySleepInhibitor {
|
||||
fn acquire(&mut self) {}
|
||||
|
||||
fn release(&mut self) {}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! 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.
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
mod dummy;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_inhibitor;
|
||||
|
||||
use std::fmt::Debug;
|
||||
|
||||
/// Keeps the machine awake while a turn is in progress when enabled.
|
||||
#[derive(Debug)]
|
||||
pub struct SleepInhibitor {
|
||||
enabled: bool,
|
||||
platform: Box<dyn PlatformSleepInhibitor>,
|
||||
}
|
||||
|
||||
pub(crate) trait PlatformSleepInhibitor: Debug {
|
||||
fn acquire(&mut self);
|
||||
fn release(&mut self);
|
||||
}
|
||||
|
||||
impl SleepInhibitor {
|
||||
pub fn new(enabled: bool) -> Self {
|
||||
#[cfg(target_os = "macos")]
|
||||
let platform: Box<dyn PlatformSleepInhibitor> =
|
||||
Box::new(macos_inhibitor::MacOsSleepInhibitor::new());
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let platform: Box<dyn PlatformSleepInhibitor> = Box::new(dummy::DummySleepInhibitor::new());
|
||||
|
||||
Self { enabled, platform }
|
||||
}
|
||||
|
||||
/// Update the active turn state; turns sleep prevention on/off as needed.
|
||||
pub fn set_turn_running(&mut self, turn_running: bool) {
|
||||
if !self.enabled {
|
||||
self.release();
|
||||
return;
|
||||
}
|
||||
|
||||
if turn_running {
|
||||
self.acquire();
|
||||
} else {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
|
||||
fn acquire(&mut self) {
|
||||
self.platform.acquire();
|
||||
}
|
||||
|
||||
fn release(&mut self) {
|
||||
self.platform.release();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SleepInhibitor;
|
||||
|
||||
#[test]
|
||||
fn sleep_inhibitor_toggles_without_panicking() {
|
||||
let mut inhibitor = SleepInhibitor::new(true);
|
||||
inhibitor.set_turn_running(true);
|
||||
inhibitor.set_turn_running(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_inhibitor_disabled_does_not_panic() {
|
||||
let mut inhibitor = SleepInhibitor::new(false);
|
||||
inhibitor.set_turn_running(true);
|
||||
inhibitor.set_turn_running(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_inhibitor_multiple_true_calls_are_idempotent() {
|
||||
let mut inhibitor = SleepInhibitor::new(true);
|
||||
inhibitor.set_turn_running(true);
|
||||
inhibitor.set_turn_running(true);
|
||||
inhibitor.set_turn_running(true);
|
||||
inhibitor.set_turn_running(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sleep_inhibitor_can_toggle_multiple_times() {
|
||||
let mut inhibitor = SleepInhibitor::new(true);
|
||||
inhibitor.set_turn_running(true);
|
||||
inhibitor.set_turn_running(false);
|
||||
inhibitor.set_turn_running(true);
|
||||
inhibitor.set_turn_running(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use crate::PlatformSleepInhibitor;
|
||||
use core_foundation::base::TCFType;
|
||||
use core_foundation::string::CFString;
|
||||
use core_foundation::string::CFStringRef;
|
||||
use std::fmt::Debug;
|
||||
use std::sync::OnceLock;
|
||||
use tracing::warn;
|
||||
|
||||
const ASSERTION_REASON: &str = "Codex is running an active turn";
|
||||
const MACOS_IDLE_SLEEP_ASSERTION_TYPE: &str = "PreventUserIdleSystemSleep";
|
||||
const IOKIT_FRAMEWORK_BINARY: &[u8] = b"/System/Library/Frameworks/IOKit.framework/IOKit\0";
|
||||
const IOPM_ASSERTION_CREATE_WITH_NAME_SYMBOL: &[u8] = b"IOPMAssertionCreateWithName\0";
|
||||
const IOPM_ASSERTION_RELEASE_SYMBOL: &[u8] = b"IOPMAssertionRelease\0";
|
||||
const IOKIT_ASSERTION_API_UNAVAILABLE: &str = "IOKit power assertion APIs are unavailable";
|
||||
|
||||
type IOPMAssertionReleaseFn = unsafe extern "C" fn(assertion_id: IOPMAssertionID) -> IOReturn;
|
||||
type IOPMAssertionID = u32;
|
||||
type IOPMAssertionLevel = u32;
|
||||
type IOReturn = i32;
|
||||
|
||||
const K_IOPM_ASSERTION_LEVEL_ON: IOPMAssertionLevel = 255;
|
||||
const K_IORETURN_SUCCESS: IOReturn = 0;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct MacOsSleepInhibitor {
|
||||
assertion: Option<MacSleepAssertion>,
|
||||
}
|
||||
|
||||
impl MacOsSleepInhibitor {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PlatformSleepInhibitor for MacOsSleepInhibitor {
|
||||
fn acquire(&mut self) {
|
||||
if self.assertion.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
match MacSleepAssertion::create(ASSERTION_REASON) {
|
||||
Ok(assertion) => {
|
||||
self.assertion = Some(assertion);
|
||||
}
|
||||
Err(error) => match error {
|
||||
MacSleepAssertionError::ApiUnavailable(reason) => {
|
||||
warn!(reason, "Failed to create macOS sleep-prevention assertion");
|
||||
}
|
||||
MacSleepAssertionError::Iokit(code) => {
|
||||
warn!(
|
||||
iokit_error = code,
|
||||
"Failed to create macOS sleep-prevention assertion"
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn release(&mut self) {
|
||||
self.assertion = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MacSleepAssertion {
|
||||
id: IOPMAssertionID,
|
||||
}
|
||||
|
||||
impl MacSleepAssertion {
|
||||
fn create(name: &str) -> Result<Self, MacSleepAssertionError> {
|
||||
let Some(api) = MacSleepApi::get() else {
|
||||
return Err(MacSleepAssertionError::ApiUnavailable(
|
||||
IOKIT_ASSERTION_API_UNAVAILABLE,
|
||||
));
|
||||
};
|
||||
|
||||
let assertion_type = CFString::new(MACOS_IDLE_SLEEP_ASSERTION_TYPE);
|
||||
let assertion_name = CFString::new(name);
|
||||
let mut id: IOPMAssertionID = 0;
|
||||
let result = unsafe {
|
||||
(api.create_with_name)(
|
||||
assertion_type.as_concrete_TypeRef(),
|
||||
K_IOPM_ASSERTION_LEVEL_ON,
|
||||
assertion_name.as_concrete_TypeRef(),
|
||||
&mut id,
|
||||
)
|
||||
};
|
||||
if result == K_IORETURN_SUCCESS {
|
||||
Ok(Self { id })
|
||||
} else {
|
||||
Err(MacSleepAssertionError::Iokit(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MacSleepAssertion {
|
||||
fn drop(&mut self) {
|
||||
let Some(api) = MacSleepApi::get() else {
|
||||
warn!(
|
||||
reason = IOKIT_ASSERTION_API_UNAVAILABLE,
|
||||
"Failed to release macOS sleep-prevention assertion"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let result = unsafe { (api.release)(self.id) };
|
||||
if result != K_IORETURN_SUCCESS {
|
||||
warn!(
|
||||
iokit_error = result,
|
||||
"Failed to release macOS sleep-prevention assertion"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum MacSleepAssertionError {
|
||||
ApiUnavailable(&'static str),
|
||||
Iokit(IOReturn),
|
||||
}
|
||||
|
||||
type IOPMAssertionCreateWithNameFn = unsafe extern "C" fn(
|
||||
assertion_type: CFStringRef,
|
||||
assertion_level: IOPMAssertionLevel,
|
||||
assertion_name: CFStringRef,
|
||||
assertion_id: *mut IOPMAssertionID,
|
||||
) -> IOReturn;
|
||||
|
||||
struct MacSleepApi {
|
||||
// Keep the dlopen handle alive for the lifetime of the loaded symbols.
|
||||
// This prevents accidental dlclose while function pointers are in use.
|
||||
_iokit_handle: usize,
|
||||
create_with_name: IOPMAssertionCreateWithNameFn,
|
||||
release: IOPMAssertionReleaseFn,
|
||||
}
|
||||
|
||||
impl MacSleepApi {
|
||||
fn get() -> Option<&'static Self> {
|
||||
static API: OnceLock<Option<MacSleepApi>> = OnceLock::new();
|
||||
API.get_or_init(Self::load).as_ref()
|
||||
}
|
||||
|
||||
fn load() -> Option<Self> {
|
||||
let handle = unsafe {
|
||||
libc::dlopen(
|
||||
IOKIT_FRAMEWORK_BINARY.as_ptr().cast(),
|
||||
libc::RTLD_LOCAL | libc::RTLD_LAZY,
|
||||
)
|
||||
};
|
||||
if handle.is_null() {
|
||||
warn!(framework = "IOKit", "Failed to open IOKit framework");
|
||||
return None;
|
||||
}
|
||||
|
||||
let create_with_name = unsafe {
|
||||
libc::dlsym(
|
||||
handle,
|
||||
IOPM_ASSERTION_CREATE_WITH_NAME_SYMBOL.as_ptr().cast(),
|
||||
)
|
||||
};
|
||||
if create_with_name.is_null() {
|
||||
warn!(
|
||||
symbol = "IOPMAssertionCreateWithName",
|
||||
"Failed to load IOKit symbol"
|
||||
);
|
||||
let _ = unsafe { libc::dlclose(handle) };
|
||||
return None;
|
||||
}
|
||||
|
||||
let release = unsafe { libc::dlsym(handle, IOPM_ASSERTION_RELEASE_SYMBOL.as_ptr().cast()) };
|
||||
if release.is_null() {
|
||||
warn!(
|
||||
symbol = "IOPMAssertionRelease",
|
||||
"Failed to load IOKit symbol"
|
||||
);
|
||||
let _ = unsafe { libc::dlclose(handle) };
|
||||
return None;
|
||||
}
|
||||
|
||||
let create_with_name: IOPMAssertionCreateWithNameFn =
|
||||
unsafe { std::mem::transmute(create_with_name) };
|
||||
let release: IOPMAssertionReleaseFn = unsafe { std::mem::transmute(release) };
|
||||
|
||||
Some(Self {
|
||||
_iokit_handle: handle as usize,
|
||||
create_with_name,
|
||||
release,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user