Files
wehub-resource-sync a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

296 lines
9.9 KiB
Rust

//! Process-wide "keep the machine awake while jcode is working" inhibitor.
//!
//! The shared `jcode serve` daemon hosts every session, so a single inhibitor
//! living in that process is enough to keep the laptop awake while *any* session
//! is streaming/processing (the same signal Waybar surfaces as "N streaming").
//!
//! The helper process is only kept alive while active work exists, then killed
//! immediately so normal power management resumes the moment work finishes.
//!
//! ## Crash / reload safety
//!
//! The daemon reloads itself with `execv` (the PID stays the same but the
//! process image is replaced) and can also be `kill -9`'d. In both cases a
//! child spawned with `sleep infinity` would be orphaned and hold the inhibitor
//! lock forever. To make leaks self-heal, the helper is spawned with a bounded
//! TTL (`sleep <TTL>`) and refreshed periodically while work continues. After a
//! crash or reload the stale lock expires within at most `INHIBIT_TTL`, and the
//! freshly-started process re-acquires on its next reconcile tick.
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
/// Legacy/global override shared with the desktop app: when set, never inhibit.
const DISABLE_ENV: &str = "JCODE_DISABLE_POWER_INHIBIT";
/// How long each spawned helper holds the lock before it must be refreshed.
/// Bounding this is what makes orphaned locks self-heal after a crash/reload.
const INHIBIT_TTL: Duration = Duration::from_secs(150);
/// Refresh once the current helper has been held longer than this. Kept well
/// below `INHIBIT_TTL` so coverage never lapses between reconcile ticks.
const INHIBIT_REFRESH_AFTER: Duration = Duration::from_secs(90);
/// Best-effort inhibitor that keeps the machine awake while jcode is actively
/// streaming/processing.
pub struct PowerInhibitor {
child: Option<Child>,
acquired_at: Option<Instant>,
available: bool,
}
impl Default for PowerInhibitor {
fn default() -> Self {
Self::new()
}
}
impl PowerInhibitor {
/// Build an inhibitor. The inhibitor is "available" on supported platforms
/// unless the legacy `JCODE_DISABLE_POWER_INHIBIT` env escape hatch is set.
///
/// The user-facing config toggle is intentionally *not* baked in here: the
/// caller evaluates it per-reconcile (via [`PowerInhibitor::set_active`]) so
/// it can be flipped at runtime in either direction without a restart.
pub fn new() -> Self {
Self {
child: None,
acquired_at: None,
available: power_inhibit_available(
std::env::var_os(DISABLE_ENV).is_some(),
current_platform(),
),
}
}
/// Whether this inhibitor can actually do anything on this platform/env.
pub fn is_available(&self) -> bool {
self.available
}
/// Reconcile the helper process against the desired active state. Safe to
/// call frequently; it is idempotent and also refreshes the bounded TTL.
pub fn set_active(&mut self, active: bool) {
if !self.available {
return;
}
if active {
self.acquire();
} else {
self.release();
}
}
fn acquire(&mut self) {
let now = Instant::now();
let healthy = self.child.as_mut().is_some_and(child_is_running);
let fresh = self
.acquired_at
.is_some_and(|at| !should_refresh(at, now, INHIBIT_REFRESH_AFTER));
if healthy && fresh {
return;
}
// Either there is no helper, it exited, or its TTL is close to expiring:
// (re)spawn a fresh one and drop the old.
self.release();
let Some(platform) = current_platform() else {
self.available = false;
return;
};
match build_inhibit_command(platform, INHIBIT_TTL).spawn() {
Ok(child) => {
self.child = Some(child);
self.acquired_at = Some(now);
}
Err(error) => {
crate::logging::warn(&format!(
"power_inhibit: failed to acquire inhibitor: {error}"
));
self.available = false;
}
}
}
fn release(&mut self) {
self.acquired_at = None;
if let Some(mut child) = self.child.take() {
if let Err(error) = child.kill() {
crate::logging::warn(&format!(
"power_inhibit: failed to stop inhibitor process: {error}"
));
}
if let Err(error) = child.wait() {
crate::logging::warn(&format!(
"power_inhibit: failed to reap inhibitor process: {error}"
));
}
}
}
}
impl Drop for PowerInhibitor {
fn drop(&mut self) {
self.release();
}
}
fn child_is_running(child: &mut Child) -> bool {
matches!(child.try_wait(), Ok(None))
}
/// Whether a helper acquired at `acquired_at` should be refreshed by `now`.
fn should_refresh(acquired_at: Instant, now: Instant, refresh_after: Duration) -> bool {
now.saturating_duration_since(acquired_at) >= refresh_after
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum InhibitPlatform {
LinuxSystemd,
MacosCaffeinate,
}
fn power_inhibit_available(
legacy_disable_present: bool,
platform: Option<InhibitPlatform>,
) -> bool {
!legacy_disable_present && platform.is_some()
}
fn current_platform() -> Option<InhibitPlatform> {
if cfg!(target_os = "linux") {
Some(InhibitPlatform::LinuxSystemd)
} else if cfg!(target_os = "macos") {
Some(InhibitPlatform::MacosCaffeinate)
} else {
None
}
}
fn build_inhibit_command(platform: InhibitPlatform, ttl: Duration) -> Command {
match platform {
InhibitPlatform::LinuxSystemd => build_linux_systemd_inhibit_command(ttl),
InhibitPlatform::MacosCaffeinate => build_macos_caffeinate_command(ttl),
}
}
fn build_linux_systemd_inhibit_command(ttl: Duration) -> Command {
let mut command = Command::new("systemd-inhibit");
command
.arg("--what=sleep:handle-lid-switch")
.arg("--who=jcode")
.arg("--why=Jcode is streaming or processing active work")
.arg("--mode=block")
.arg("sleep")
.arg(ttl.as_secs().to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
command
}
fn build_macos_caffeinate_command(ttl: Duration) -> Command {
let mut command = Command::new("caffeinate");
command
// -i prevents idle sleep. -s prevents system sleep while on AC power.
// We intentionally do not use -d so the display can still sleep/turn off.
// -t bounds the assertion so a crashed/reloaded daemon self-heals.
.arg("-i")
.arg("-s")
.arg("-t")
.arg(ttl.as_secs().to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
command
}
#[cfg(test)]
mod tests {
use super::{INHIBIT_TTL, InhibitPlatform, should_refresh};
use std::time::{Duration, Instant};
fn command_name(command: &std::process::Command) -> String {
command.get_program().to_string_lossy().to_string()
}
fn command_args(command: &std::process::Command) -> Vec<String> {
command
.get_args()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>()
}
#[test]
fn availability_requires_no_legacy_disable_and_supported_platform() {
assert!(super::power_inhibit_available(
false,
Some(InhibitPlatform::LinuxSystemd),
));
// Legacy env escape hatch wins.
assert!(!super::power_inhibit_available(
true,
Some(InhibitPlatform::LinuxSystemd),
));
// Unsupported platform.
assert!(!super::power_inhibit_available(false, None));
}
#[test]
fn linux_inhibitor_blocks_sleep_and_lid_switch_with_bounded_ttl() {
let command = super::build_inhibit_command(InhibitPlatform::LinuxSystemd, INHIBIT_TTL);
let args = command_args(&command);
assert_eq!(command_name(&command), "systemd-inhibit");
assert!(args.contains(&"--what=sleep:handle-lid-switch".to_string()));
assert!(args.contains(&"--who=jcode".to_string()));
assert!(args.contains(&"--mode=block".to_string()));
assert!(args.contains(&"sleep".to_string()));
// Bounded TTL (not "infinity") so orphaned locks self-heal.
assert!(args.contains(&INHIBIT_TTL.as_secs().to_string()));
assert!(!args.contains(&"infinity".to_string()));
}
#[test]
fn macos_inhibitor_prevents_system_sleep_without_display_assertion() {
let command = super::build_inhibit_command(InhibitPlatform::MacosCaffeinate, INHIBIT_TTL);
let args = command_args(&command);
assert_eq!(command_name(&command), "caffeinate");
assert!(args.contains(&"-i".to_string()));
assert!(args.contains(&"-s".to_string()));
assert!(!args.contains(&"-d".to_string()));
assert!(args.contains(&"-t".to_string()));
assert!(args.contains(&INHIBIT_TTL.as_secs().to_string()));
}
#[test]
fn refresh_is_due_only_after_the_threshold_elapses() {
let acquired = Instant::now();
let refresh_after = Duration::from_secs(90);
assert!(!should_refresh(
acquired,
acquired + Duration::from_secs(30),
refresh_after
));
assert!(!should_refresh(
acquired,
acquired + Duration::from_secs(89),
refresh_after
));
assert!(should_refresh(
acquired,
acquired + Duration::from_secs(90),
refresh_after
));
assert!(should_refresh(
acquired,
acquired + Duration::from_secs(120),
refresh_after
));
}
}