Skip to content

Commit 9db7980

Browse files
committed
Prevent macOS sleep while streaming
1 parent c74b9c9 commit 9db7980

2 files changed

Lines changed: 179 additions & 2 deletions

File tree

src/platform.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,130 @@
11
use std::path::Path;
22

3+
#[cfg(target_os = "macos")]
4+
mod macos_power {
5+
use std::ffi::CString;
6+
use std::os::raw::{c_char, c_void};
7+
8+
type CFStringRef = *const c_void;
9+
type IOPMAssertionID = u32;
10+
type IOReturn = i32;
11+
12+
const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100;
13+
const K_IOPM_ASSERTION_LEVEL_ON: u32 = 255;
14+
const K_IO_RETURN_SUCCESS: IOReturn = 0;
15+
16+
#[link(name = "CoreFoundation", kind = "framework")]
17+
unsafe extern "C" {
18+
fn CFStringCreateWithCString(
19+
alloc: *const c_void,
20+
c_str: *const c_char,
21+
encoding: u32,
22+
) -> CFStringRef;
23+
fn CFRelease(cf: *const c_void);
24+
}
25+
26+
#[link(name = "IOKit", kind = "framework")]
27+
unsafe extern "C" {
28+
fn IOPMAssertionCreateWithName(
29+
assertion_type: CFStringRef,
30+
assertion_level: u32,
31+
assertion_name: CFStringRef,
32+
assertion_id: *mut IOPMAssertionID,
33+
) -> IOReturn;
34+
fn IOPMAssertionRelease(assertion_id: IOPMAssertionID) -> IOReturn;
35+
}
36+
37+
fn cf_string(value: &str) -> Option<CFStringRef> {
38+
let c_string = CString::new(value).ok()?;
39+
let cf = unsafe {
40+
CFStringCreateWithCString(
41+
std::ptr::null(),
42+
c_string.as_ptr(),
43+
K_CF_STRING_ENCODING_UTF8,
44+
)
45+
};
46+
(!cf.is_null()).then_some(cf)
47+
}
48+
49+
pub struct PowerAssertion {
50+
id: Option<IOPMAssertionID>,
51+
}
52+
53+
impl PowerAssertion {
54+
pub fn prevent_user_idle_system_sleep(reason: &str) -> Self {
55+
let Some(assertion_type) = cf_string("PreventUserIdleSystemSleep") else {
56+
return Self { id: None };
57+
};
58+
let Some(assertion_name) = cf_string(reason) else {
59+
unsafe { CFRelease(assertion_type) };
60+
return Self { id: None };
61+
};
62+
63+
let mut id = 0;
64+
let result = unsafe {
65+
IOPMAssertionCreateWithName(
66+
assertion_type,
67+
K_IOPM_ASSERTION_LEVEL_ON,
68+
assertion_name,
69+
&mut id,
70+
)
71+
};
72+
unsafe {
73+
CFRelease(assertion_type);
74+
CFRelease(assertion_name);
75+
}
76+
77+
if result == K_IO_RETURN_SUCCESS {
78+
crate::logging::info(&format!(
79+
"Created macOS sleep-prevention assertion while streaming (id={id})"
80+
));
81+
Self { id: Some(id) }
82+
} else {
83+
crate::logging::warn(&format!(
84+
"Failed to create macOS sleep-prevention assertion while streaming: IOReturn={result}"
85+
));
86+
Self { id: None }
87+
}
88+
}
89+
90+
#[cfg(test)]
91+
pub fn is_active(&self) -> bool {
92+
self.id.is_some()
93+
}
94+
}
95+
96+
impl Drop for PowerAssertion {
97+
fn drop(&mut self) {
98+
if let Some(id) = self.id.take() {
99+
let result = unsafe { IOPMAssertionRelease(id) };
100+
if result != K_IO_RETURN_SUCCESS {
101+
crate::logging::warn(&format!(
102+
"Failed to release macOS sleep-prevention assertion id={id}: IOReturn={result}"
103+
));
104+
}
105+
}
106+
}
107+
}
108+
}
109+
110+
#[cfg(not(target_os = "macos"))]
111+
mod macos_power {
112+
pub struct PowerAssertion;
113+
114+
impl PowerAssertion {
115+
pub fn prevent_user_idle_system_sleep(_reason: &str) -> Self {
116+
Self
117+
}
118+
119+
#[cfg(test)]
120+
pub fn is_active(&self) -> bool {
121+
false
122+
}
123+
}
124+
}
125+
126+
pub use macos_power::PowerAssertion;
127+
3128
fn desired_nofile_soft_limit(current: u64, hard: u64, minimum: u64) -> Option<u64> {
4129
let desired = current.max(minimum).min(hard);
5130
(desired > current).then_some(desired)

src/session/active_pids.rs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,21 @@ pub fn unmark_streaming(session_id: &str) {
4848
/// never gets stuck showing a phantom streaming session.
4949
pub struct StreamingGuard {
5050
session_id: String,
51+
#[allow(dead_code)]
52+
sleep_assertion: crate::platform::PowerAssertion,
5153
}
5254

5355
impl StreamingGuard {
5456
pub fn new(session_id: impl Into<String>) -> Self {
5557
let session_id = session_id.into();
5658
mark_streaming(&session_id);
57-
Self { session_id }
59+
let sleep_assertion = crate::platform::PowerAssertion::prevent_user_idle_system_sleep(
60+
"Jcode streaming model response",
61+
);
62+
Self {
63+
session_id,
64+
sleep_assertion,
65+
}
5866
}
5967
}
6068

@@ -174,7 +182,10 @@ mod tests {
174182

175183
let counts = session_counts();
176184
assert_eq!(counts.total, 3, "three live sessions expected");
177-
assert_eq!(counts.streaming, 1, "only one live streaming session expected");
185+
assert_eq!(
186+
counts.streaming, 1,
187+
"only one live streaming session expected"
188+
);
178189

179190
// Clearing the streaming marker drops the streaming count.
180191
unmark_streaming("session_alpha");
@@ -206,4 +217,45 @@ mod tests {
206217

207218
crate::env::remove_var("JCODE_HOME");
208219
}
220+
221+
#[cfg(target_os = "macos")]
222+
#[test]
223+
fn streaming_guard_creates_visible_macos_sleep_assertion() {
224+
let _guard = crate::storage::lock_test_env();
225+
let temp = tempfile::tempdir().expect("tempdir");
226+
crate::env::set_var("JCODE_HOME", temp.path());
227+
228+
let reason = "Jcode streaming model response";
229+
register_active_pid("session_power", std::process::id());
230+
{
231+
let streaming = StreamingGuard::new("session_power");
232+
assert!(
233+
streaming.sleep_assertion.is_active(),
234+
"macOS should create a native power assertion"
235+
);
236+
237+
let output = std::process::Command::new("pmset")
238+
.args(["-g", "assertions"])
239+
.output()
240+
.expect("pmset -g assertions should run on macOS");
241+
assert!(output.status.success(), "pmset should succeed");
242+
let stdout = String::from_utf8_lossy(&output.stdout);
243+
assert!(
244+
stdout.contains(reason),
245+
"pmset output should show the streaming assertion; output was:\n{stdout}"
246+
);
247+
}
248+
249+
let output = std::process::Command::new("pmset")
250+
.args(["-g", "assertions"])
251+
.output()
252+
.expect("pmset -g assertions should run on macOS");
253+
let stdout = String::from_utf8_lossy(&output.stdout);
254+
assert!(
255+
!stdout.contains(reason),
256+
"streaming assertion should be released after guard drop; output was:\n{stdout}"
257+
);
258+
259+
crate::env::remove_var("JCODE_HOME");
260+
}
209261
}

0 commit comments

Comments
 (0)