diff --git a/cmux-tui/crates/acpmux/docs/background-processes.md b/cmux-tui/crates/acpmux/docs/background-processes.md index a02c9748354e..f40806fbb616 100644 --- a/cmux-tui/crates/acpmux/docs/background-processes.md +++ b/cmux-tui/crates/acpmux/docs/background-processes.md @@ -184,6 +184,7 @@ process's command line and its kill never leave the machine. | Chat closed, no client attached | Keeps running: a running process blocks the idle reap. | | Over the session's memory budget (cx-v9s8g) | Enforced by the kernel, not by acpmux measuring: on Linux the session's cgroup scope (`MemoryMax`); `killed`, reason `budget_memory`. | | Over the CPU budget (cx-v9s8g) | Lower priority, not killed: the cgroup's `CPUWeight` on Linux, `setpriority(PRIO_PGRP)` on macOS. | +| Start (macOS) | The harness starts at background CPU class (`PRIO_DARWIN_BG`, `cpu_class.rs`), so every process it runs inherits it and cmux's GUI and renderers go first. `normal` opts out: a profile's env `ACPMUX_SESSION_CPU_CLASS`, the daemon's env, or config `session.cpuClass`. | | Harness time limit (Claude's `timeout`; acpmux lifts the ceiling to about 24.8 days) | `killed`, reason `harness_time_limit`. | | Daemon restart | Keeps running (the agent host owns the harness); the list is rebuilt from the `process_update` records. A process still `running` there stays so until its harness reports its end. | | Harness or host exit, app update relaunch | `lost` (built), with the last output kept and Run again offered. A real reattach needs Claude Code's background sessions (`claude agents`, 2.1.196); evaluated separately. | diff --git a/cmux-tui/crates/acpmux/src/agent.rs b/cmux-tui/crates/acpmux/src/agent.rs index 94aad8f478f1..f4b6a5e87434 100644 --- a/cmux-tui/crates/acpmux/src/agent.rs +++ b/cmux-tui/crates/acpmux/src/agent.rs @@ -716,6 +716,11 @@ impl ChildAgent { let preview_format = Some(pinned_preview_format(&cmd)); #[cfg(windows)] cmd.creation_flags(crate::job::AGENT_FLAGS); + // Below the GUI's CPU class (macOS), the tools it starts with it (`cpu_class.rs`). + // From the harness's own env: its profile's value as resolved. + let env = command_env(&cmd); + crate::cpu_class::CpuClass::for_session(env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .apply(&mut cmd); let mut child = cmd.spawn().map_err(|e| spawn_error(&format!("spawn agent {name}"), e))?; #[cfg(windows)] let job = { diff --git a/cmux-tui/crates/acpmux/src/agent_host/host.rs b/cmux-tui/crates/acpmux/src/agent_host/host.rs index 6dff552e0441..89cf081dbe48 100644 --- a/cmux-tui/crates/acpmux/src/agent_host/host.rs +++ b/cmux-tui/crates/acpmux/src/agent_host/host.rs @@ -94,6 +94,8 @@ async fn start(spec: &SpawnSpec) -> Result { // started (background shells included). .process_group(0) .kill_on_drop(false); + // Below the GUI's CPU class (macOS), the tools it starts with it. + spec.cpu_class.apply(&mut cmd); // Bind before the harness starts, so a failure leaves nothing running. let _ = std::fs::remove_file(&spec.socket); let listener = UnixListener::bind(&spec.socket) diff --git a/cmux-tui/crates/acpmux/src/agent_host/mod.rs b/cmux-tui/crates/acpmux/src/agent_host/mod.rs index aba24c9f96f8..0ac2cc732f30 100644 --- a/cmux-tui/crates/acpmux/src/agent_host/mod.rs +++ b/cmux-tui/crates/acpmux/src/agent_host/mod.rs @@ -214,6 +214,10 @@ pub struct SpawnSpec { /// (`cwd_pin.rs`). The host's harness spawn fails when `cwd` leads elsewhere by then. #[serde(default)] pub cwd_pin: Option, + /// The CPU class the harness starts at (`cpu_class.rs`), decided by the daemon. A spec from + /// a daemon that predates it starts the harness as before. + #[serde(default)] + pub cpu_class: crate::cpu_class::CpuClass, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/cmux-tui/crates/acpmux/src/cli/command.rs b/cmux-tui/crates/acpmux/src/cli/command.rs index 3a0ad809ae6d..493396efe615 100644 --- a/cmux-tui/crates/acpmux/src/cli/command.rs +++ b/cmux-tui/crates/acpmux/src/cli/command.rs @@ -543,7 +543,8 @@ pub enum Command { show_token: bool, /// Set a daemon host-wide setting as KEY=VALUE (for example, /// `session.memory_mb=6144`, `session.swap_mb=2048`, - /// `session.tasks_max=8192` or `session.sessions_tasks_max=131072`). + /// `session.tasks_max=8192`, `session.sessions_tasks_max=131072` or + /// `session.cpu_class=normal`). #[arg(long, value_name = "KEY=VALUE")] set: Option, }, diff --git a/cmux-tui/crates/acpmux/src/config.rs b/cmux-tui/crates/acpmux/src/config.rs index bff454d26f32..80d1d5f8ab6f 100644 --- a/cmux-tui/crates/acpmux/src/config.rs +++ b/cmux-tui/crates/acpmux/src/config.rs @@ -820,9 +820,10 @@ pub struct TitlesConfig { pub model: Option, } -/// Host-wide limits applied to every Linux session scope. These are daemon -/// settings, rather than spawn environment, so a CLI can set them without -/// crossing the app-only spawn-env gate. +/// Host-wide limits applied to every Linux session scope, and the CPU class +/// of every macOS session's harness. These are daemon settings, rather than +/// spawn environment, so a CLI can set them without crossing the app-only +/// spawn-env gate. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub struct SessionResourceConfig { @@ -843,6 +844,11 @@ pub struct SessionResourceConfig { /// kernel's `threads-max`, between 16384 and 262144. #[serde(default, skip_serializing_if = "Option::is_none")] pub sessions_tasks_max: Option, + /// `cpuClass`: the CPU class a macOS session's harness starts at, + /// `background` or `normal` (`cpu_class.rs`). Env `ACPMUX_SESSION_CPU_CLASS` + /// overrides it, a profile's env overrides both. Unset: `background`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cpu_class: Option, } impl SessionResourceConfig { @@ -1089,6 +1095,19 @@ impl Config { /// `none`, and `clear` remove an override and restore the computed default. pub fn set_session_resource(&mut self, key: &str, value: &str) -> Result<()> { let key = key.trim().replace('-', "_").to_ascii_lowercase(); + if matches!( + key.as_str(), + "session.cpu_class" | "session.cpuclass" | "cpu_class" | "cpuclass" + ) { + let value = value.trim(); + self.session.cpu_class = match value.to_ascii_lowercase().as_str() { + "" | "default" | "none" | "clear" => None, + _ => Some(crate::cpu_class::CpuClass::parse(value).ok_or_else(|| { + anyhow::anyhow!("{key} must be `background`, `normal` or `default`") + })?), + }; + return Ok(()); + } let slot = match key.as_str() { "session.memory_mb" | "session.memorymb" | "memory_mb" | "memorymb" => { &mut self.session.memory_mb @@ -1105,7 +1124,8 @@ impl Config { | "sessionstasksmax" => &mut self.session.sessions_tasks_max, _ => anyhow::bail!( "unknown session resource setting `{key}` (use session.memory_mb, \ - session.swap_mb, session.tasks_max or session.sessions_tasks_max)" + session.swap_mb, session.tasks_max, session.sessions_tasks_max or \ + session.cpu_class)" ), }; let tasks = key.contains("tasks"); diff --git a/cmux-tui/crates/acpmux/src/config/tests.rs b/cmux-tui/crates/acpmux/src/config/tests.rs index 3405997dfeff..b6781b4b5e6b 100644 --- a/cmux-tui/crates/acpmux/src/config/tests.rs +++ b/cmux-tui/crates/acpmux/src/config/tests.rs @@ -31,6 +31,23 @@ fn session_task_caps_are_typed_config_values() { assert!(config.set_session_resource("session.tasks_max", "lots").is_err()); } +#[test] +fn cli_session_cpu_class_is_a_config_value() { + use crate::cpu_class::CpuClass; + let mut config = Config::default(); + config.set_session_resource("session.cpu_class", "normal").unwrap(); + assert_eq!(config.session.cpu_class, Some(CpuClass::Normal)); + let saved = serde_json::to_value(&config).unwrap(); + assert_eq!(saved["session"]["cpuClass"], "normal"); + let loaded: Config = serde_json::from_value(saved).unwrap(); + assert_eq!(loaded.session.cpu_class, Some(CpuClass::Normal)); + config.set_session_resource("session.cpu-class", "Background").unwrap(); + assert_eq!(config.session.cpu_class, Some(CpuClass::Background)); + assert!(config.set_session_resource("session.cpu_class", "utility").is_err()); + config.set_session_resource("session.cpu_class", "default").unwrap(); + assert_eq!(config.session.cpu_class, None); +} + fn prof(kind: HarnessKind, argv: &[&str]) -> HarnessProfile { HarnessProfile { kind, diff --git a/cmux-tui/crates/acpmux/src/cpu_class.rs b/cmux-tui/crates/acpmux/src/cpu_class.rs new file mode 100644 index 000000000000..a4211e9e654a --- /dev/null +++ b/cmux-tui/crates/acpmux/src/cpu_class.rs @@ -0,0 +1,123 @@ +//! The CPU class a session's harness starts at (macOS). Agent harnesses and the tools they run +//! (builds, tests) otherwise compete with cmux and its Chromium renderers at the same priority, +//! and a busy Mac makes a dragged pane's page trail it (cmuxterm-hq#4947: p95 55 ms with 14 busy +//! processes at the default class, 6 ms with the same load at background class). +//! +//! `background` (the default) starts the harness with `PRIO_DARWIN_BG`, as cmux-git starts its +//! git children (`cmux-git` `run.rs`): the lowest CPU priority, throttled disk I/O and +//! background-class new sockets. It is set in the harness process between fork and exec, so the +//! harness and every process it starts afterwards inherit it; a running process is never +//! reniced. `normal` leaves the harness at the daemon's class. +//! +//! The class, first set wins: the profile's env `ACPMUX_SESSION_CPU_CLASS` as the harness gets it +//! (its `${env:…}` references resolved; one build-heavy profile can opt out), the daemon's env +//! `ACPMUX_SESSION_CPU_CLASS`, config `session.cpuClass` +//! (`acpmux config --set session.cpu_class=normal`), then `background`. Linux sessions get their +//! CPU share from their cgroup scope's `CPUWeight` instead (`session_scope.rs`), and other +//! platforms start harnesses as before: there the class is read and ignored. + +use serde::{Deserialize, Serialize}; + +/// The knob, in a profile's env or the daemon's. +pub const ENV: &str = "ACPMUX_SESSION_CPU_CLASS"; + +/// The CPU class a session's harness starts at. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CpuClass { + /// The daemon's own class: nothing changes at spawn. + #[default] + Normal, + /// `PRIO_DARWIN_BG` on macOS. + Background, +} + +/// The class a session starts at when nothing names one. +pub const DEFAULT: CpuClass = CpuClass::Background; + +impl CpuClass { + /// `normal` or `background` (any case, trimmed); None for anything else. + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "normal" => Some(Self::Normal), + "background" => Some(Self::Background), + _ => None, + } + } + + /// The class of a session whose harness gets the environment `harness_env` (the command's + /// complete one: the profile's values resolved, the daemon's own `ACPMUX_*` removed), with the + /// daemon's env and config read now (at each spawn, as the session scope's limits are). + #[cfg(target_os = "macos")] + pub fn for_session<'a>(harness_env: impl IntoIterator) -> Self { + let profile = harness_env.into_iter().find(|(k, _)| *k == ENV).map(|(_, v)| v); + let config = crate::config::Config::load().ok().and_then(|c| c.session.cpu_class); + Self::resolve(profile, std::env::var(ENV).ok(), config) + } + + /// Off macOS: `normal`, with nothing read. + #[cfg(not(target_os = "macos"))] + pub fn for_session<'a>(_harness_env: impl IntoIterator) -> Self { + Self::Normal + } + + /// The first valid of the profile's value, the daemon's env value and config's, else + /// [`DEFAULT`]. A value that names no class is skipped, with a warning. + pub fn resolve(profile: Option<&str>, daemon: Option, config: Option) -> Self { + let named = |source: &str, value: Option<&str>| { + let value = value?; + let class = Self::parse(value); + if class.is_none() { + tracing::warn!("{source} {ENV}={value:?} names no CPU class (normal, background)"); + } + class + }; + named("profile env", profile) + .or_else(|| named("daemon env", daemon.as_deref())) + .or(config) + .unwrap_or(DEFAULT) + } + + /// Starts `cmd`'s process at this class (macOS; elsewhere `cmd` is unchanged). + pub fn apply(self, cmd: &mut tokio::process::Command) { + #[cfg(target_os = "macos")] + if self == Self::Background { + // SAFETY: the hook runs in the child between fork and exec and makes one + // setpriority(2) call on itself: no allocation, no lock, async-signal-safe. It never + // fails the spawn: a harness that could not be lowered runs as before. + unsafe { + cmd.pre_exec(|| { + // : PRIO_DARWIN_PROCESS and PRIO_DARWIN_BG. + const PRIO_DARWIN_PROCESS: libc::c_int = 4; + const PRIO_DARWIN_BG: libc::c_int = 0x1000; + libc::setpriority(PRIO_DARWIN_PROCESS, 0, PRIO_DARWIN_BG); + Ok(()) + }); + } + } + #[cfg(not(target_os = "macos"))] + let _ = cmd; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_first_valid_source_names_the_class_else_background() { + let bg = Some(CpuClass::Background); + assert_eq!(CpuClass::resolve(None, None, None), CpuClass::Background); + assert_eq!(CpuClass::resolve(None, None, Some(CpuClass::Normal)), CpuClass::Normal); + // The daemon's env overrides config; the profile overrides both. + assert_eq!(CpuClass::resolve(None, Some("normal".into()), bg), CpuClass::Normal); + assert_eq!( + CpuClass::resolve(Some("background"), Some("normal".into()), Some(CpuClass::Normal)), + CpuClass::Background + ); + assert_eq!(CpuClass::resolve(Some(" Normal "), None, bg), CpuClass::Normal); + // A value that names no class is skipped. + assert_eq!(CpuClass::resolve(Some("utility"), Some("normal".into()), bg), CpuClass::Normal); + assert_eq!(CpuClass::resolve(Some(""), None, None), CpuClass::Background); + } +} diff --git a/cmux-tui/crates/acpmux/src/hub/catalog_reload.rs b/cmux-tui/crates/acpmux/src/hub/catalog_reload.rs index cabc7c8c6675..2b3d289b6a94 100644 --- a/cmux-tui/crates/acpmux/src/hub/catalog_reload.rs +++ b/cmux-tui/crates/acpmux/src/hub/catalog_reload.rs @@ -129,6 +129,7 @@ mod tests { swap_mb: Some(2048), tasks_max: Some(8192), sessions_tasks_max: Some(131_072), + cpu_class: Some(crate::cpu_class::CpuClass::Normal), }, SessionResourceConfig::default(), ] { diff --git a/cmux-tui/crates/acpmux/src/hub/hosts.rs b/cmux-tui/crates/acpmux/src/hub/hosts.rs index 600fed402a05..da3cc2fa3902 100644 --- a/cmux-tui/crates/acpmux/src/hub/hosts.rs +++ b/cmux-tui/crates/acpmux/src/hub/hosts.rs @@ -1609,15 +1609,20 @@ impl Hub { self.record_preview_format(session, crate::agent::pinned_preview_format(&cmd)); let std_cmd = cmd.as_std(); let hosts = crate::agent_host::hosts_dir(); + let env = if meta.remote_origin { + crate::agent::command_env_cleared(&cmd) + } else { + crate::agent::command_env(&cmd) + }; + // From the harness's own env: its profile's value as resolved (`cpu_class.rs`). + let cpu_class = crate::cpu_class::CpuClass::for_session( + env.iter().map(|(k, v)| (k.as_str(), v.as_str())), + ); let spec = crate::agent_host::SpawnSpec { session_id: session.id.clone(), program: std_cmd.get_program().to_string_lossy().into_owned(), args: std_cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(), - env: if meta.remote_origin { - crate::agent::command_env_cleared(&cmd) - } else { - crate::agent::command_env(&cmd) - }, + env, cwd: meta.cwd.clone(), codex_bridge, translator, @@ -1626,6 +1631,7 @@ impl Hub { buffer_cap: crate::agent_host::DEFAULT_BUFFER_CAP, router_owner_fifo: crate::daemon::router_owner_path(), cwd_pin: crate::cwd_pin::CwdPin::current(), + cpu_class, }; let launcher = crate::agent_host::link::HostLauncher::current().map_err(internal)?; // Its MCP configs are read before it starts, never after (`hub/idle_census.rs`). diff --git a/cmux-tui/crates/acpmux/src/hub/pool/start.rs b/cmux-tui/crates/acpmux/src/hub/pool/start.rs index 60453ed1654e..722a6d749fa6 100644 --- a/cmux-tui/crates/acpmux/src/hub/pool/start.rs +++ b/cmux-tui/crates/acpmux/src/hub/pool/start.rs @@ -94,15 +94,20 @@ impl Hub { let preview = crate::agent::pinned_preview_format(&cmd); let std_cmd = cmd.as_std(); let dir = pool_dir(); + let env = if draft.remote_origin { + crate::agent::command_env_cleared(&cmd) + } else { + crate::agent::command_env(&cmd) + }; + // From the harness's own env: its profile's value as resolved (`cpu_class.rs`). + let cpu_class = crate::cpu_class::CpuClass::for_session( + env.iter().map(|(k, v)| (k.as_str(), v.as_str())), + ); let host_spec = agent_host::SpawnSpec { session_id: session_id.clone(), program: std_cmd.get_program().to_string_lossy().into_owned(), args: std_cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(), - env: if draft.remote_origin { - crate::agent::command_env_cleared(&cmd) - } else { - crate::agent::command_env(&cmd) - }, + env, cwd: draft.cwd.clone(), codex_bridge: !claude && crate::codex_app_server::is_profile_launch(&profile.argv), translator, @@ -112,6 +117,7 @@ impl Hub { router_owner_fifo: crate::daemon::router_owner_path(), // Never a sub-session's checked folder: a sub-session start takes no pooled harness. cwd_pin: None, + cpu_class, }; // No host starts once `stop_pool` began: it could not end it. if self.pool.stopping.load(Ordering::SeqCst) { diff --git a/cmux-tui/crates/acpmux/src/lib.rs b/cmux-tui/crates/acpmux/src/lib.rs index fb879926b699..3f38f9289224 100644 --- a/cmux-tui/crates/acpmux/src/lib.rs +++ b/cmux-tui/crates/acpmux/src/lib.rs @@ -42,6 +42,7 @@ pub mod codex_app_server; mod codex_auth_file; pub mod complete; pub mod config; +pub mod cpu_class; pub mod cua_socket; pub mod cua_v2; pub mod cwd_pin; diff --git a/cmux-tui/crates/acpmux/src/session_scope.rs b/cmux-tui/crates/acpmux/src/session_scope.rs index 005d11854b01..1ac369de88b6 100644 --- a/cmux-tui/crates/acpmux/src/session_scope.rs +++ b/cmux-tui/crates/acpmux/src/session_scope.rs @@ -32,7 +32,7 @@ //! missing or cannot make a scope (no user manager, as in a container), the //! harness starts as before; that answer is asked once per `systemd-run` and //! limits. macOS gets the process-group half of the valve on the holder track -//! (cx-t2sqm.3). +//! (cx-t2sqm.3); its harnesses start at background CPU class (`cpu_class.rs`). #![cfg_attr(not(target_os = "linux"), allow(dead_code))] use std::collections::BTreeMap; diff --git a/cmux-tui/crates/acpmux/tests/agent_host.rs b/cmux-tui/crates/acpmux/tests/agent_host.rs index abbf37209c23..347ac14aba58 100644 --- a/cmux-tui/crates/acpmux/tests/agent_host.rs +++ b/cmux-tui/crates/acpmux/tests/agent_host.rs @@ -49,6 +49,7 @@ fn spec(dir: &Path, session: &str) -> SpawnSpec { buffer_cap: agent_host::DEFAULT_BUFFER_CAP, router_owner_fifo: None, cwd_pin: None, + cpu_class: Default::default(), } } diff --git a/cmux-tui/crates/acpmux/tests/cpu_class.rs b/cmux-tui/crates/acpmux/tests/cpu_class.rs new file mode 100644 index 000000000000..92ee79a29d1b --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/cpu_class.rs @@ -0,0 +1,184 @@ +//! A session's harness starts below the GUI's CPU class on macOS (`cpu_class.rs`): background +//! (`PRIO_DARWIN_BG`) unless its profile, the daemon's env or config asks for `normal`. The class +//! is set as the harness starts, so the tools it runs inherit it: a shell the harness put in the +//! background is lowered too, as a direct child and under an agent host. +//! +//! The stand-in harness starts `sleep` in the background, writes its own pid and the sleeper's +//! to a FIFO the test reads, then execs fake_agent.py. The class is read with `ps -o pri=`: the +//! scheduler's base priority, 4 for a background (darwinbg) process and 31 for a default one. + +#![cfg(target_os = "macos")] + +#[path = "support/daemon_env.rs"] +mod daemon_env; +// This process runs stand-in agents (the fake agents under tests/). The reference test's +// variable is set as the process starts, never while tests run. +const HERMETIC: daemon_env::Process = + daemon_env::Process::stand_in().with_env(&[("ACPMUX_TEST_CPU_CLASS_REF", "normal")]); + +use acpmux::agent::{ChildAgent, Tap}; +use acpmux::agent_host::link::{self, HostLauncher}; +use acpmux::agent_host::{self, SpawnSpec}; +use acpmux::config::HarnessProfile; +use serde_json::json; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +const FAKE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_agent.py"); +/// The scheduler's base priority of a darwinbg process (`MAXPRI_THROTTLE`). +const BACKGROUND_PRI: u32 = 4; + +fn scratch(tag: &str) -> PathBuf { + // Short: socket paths must stay under the macOS limit. + let dir = std::env::temp_dir().join(format!("acp-cpu-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::canonicalize(dir).unwrap() +} + +/// `dir/pids`, a FIFO the stand-in harness writes "HARNESS SLEEPER" to. +fn pid_fifo(dir: &Path) -> PathBuf { + let path = dir.join("pids"); + let c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()).unwrap(); + // SAFETY: valid NUL-terminated path. + assert_eq!(unsafe { libc::mkfifo(c.as_ptr(), 0o600) }, 0); + path +} + +/// The stand-in harness: a background sleeper (a tool the agent ran), both pids to `fifo`, +/// then the ACP stand-in. +fn harness_argv(fifo: &Path) -> Vec { + let script = format!( + "sleep 120 & echo \"$$ $!\" > {}; exec python3 {}", + daemon_env::sh_quote(fifo.display()), + daemon_env::sh_quote(FAKE) + ); + vec!["/bin/sh".into(), "-c".into(), script] +} + +/// The harness's and the sleeper's pids, once the harness wrote them. A harness that never +/// writes them fails the test: the blocked read is then released by a writer that closes at once, +/// so no blocking thread outlives the test. +async fn read_pids(fifo: PathBuf) -> (i32, i32) { + let path = fifo.clone(); + let mut read = tokio::task::spawn_blocking(move || std::fs::read_to_string(path).unwrap()); + let line = match tokio::time::timeout(Duration::from_secs(20), &mut read).await { + Ok(line) => line.unwrap(), + Err(_) => { + use std::os::unix::fs::OpenOptionsExt; + let _ = + std::fs::OpenOptions::new().write(true).custom_flags(libc::O_NONBLOCK).open(&fifo); + let _ = read.await; + panic!("the harness did not write its pids in time"); + } + }; + let mut pids = line.split_whitespace().map(|p| p.parse::().unwrap()); + (pids.next().unwrap(), pids.next().unwrap()) +} + +/// `ps -o pri=` of `pid`. +fn base_priority(pid: i32) -> u32 { + let out = std::process::Command::new("/bin/ps") + .args(["-o", "pri=", "-p", &pid.to_string()]) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or_else(|_| { + panic!("ps -o pri= -p {pid}: {:?}", String::from_utf8_lossy(&out.stdout)) + }) +} + +/// Ends the harness's process group (the sleeper with it). +fn end_group(harness: i32) { + // SAFETY: signals the process group the harness leads. + unsafe { libc::killpg(harness, libc::SIGKILL) }; +} + +async fn direct(dir: &Path, env: serde_json::Value) -> (Arc, i32, i32) { + let fifo = pid_fifo(dir); + let profile: HarnessProfile = + serde_json::from_value(json!({"argv": harness_argv(&fifo), "env": env})).unwrap(); + let (inbound, _rx) = tokio::sync::mpsc::channel(16); + let tap: Tap = Arc::new(|_, _, _| true); + let agent = ChildAgent::spawn_with( + "cpu", + &profile, + dir, + inbound, + tap, + None, + None, + Some(("s-cpu", "cpu")), + ) + .await + .expect("spawn the harness"); + let (harness, sleeper) = read_pids(fifo).await; + (agent, harness, sleeper) +} + +#[tokio::test] +async fn cpu_class_a_direct_harness_and_the_tools_it_runs_start_at_background_class() { + let dir = scratch("d"); + let (_agent, harness, sleeper) = direct(&dir, json!({})).await; + let (h, s) = (base_priority(harness), base_priority(sleeper)); + end_group(harness); + assert_eq!(h, BACKGROUND_PRI, "the harness runs at the GUI's class"); + assert_eq!(s, BACKGROUND_PRI, "a tool the harness started runs at the GUI's class"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[tokio::test] +async fn cpu_class_a_profile_can_keep_its_harness_at_normal_class() { + let dir = scratch("n"); + let (_agent, harness, sleeper) = + direct(&dir, json!({"ACPMUX_SESSION_CPU_CLASS": "normal"})).await; + let (h, s) = (base_priority(harness), base_priority(sleeper)); + end_group(harness); + assert!(h > BACKGROUND_PRI, "the opted-out harness was lowered: pri {h}"); + assert!(s > BACKGROUND_PRI, "the opted-out harness's tool was lowered: pri {s}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[tokio::test] +async fn cpu_class_a_profile_reference_that_resolves_to_normal_keeps_normal_class() { + let dir = scratch("r"); + // ACPMUX_TEST_CPU_CLASS_REF=normal: set for this process by HERMETIC. + let (_agent, harness, _sleeper) = + direct(&dir, json!({"ACPMUX_SESSION_CPU_CLASS": "${env:ACPMUX_TEST_CPU_CLASS_REF}"})).await; + let h = base_priority(harness); + end_group(harness); + assert!(h > BACKGROUND_PRI, "a reference that resolves to normal was lowered: pri {h}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[tokio::test] +async fn cpu_class_a_hosted_harness_and_the_tools_it_runs_start_at_background_class() { + let dir = scratch("h"); + let fifo = pid_fifo(&dir); + let hosts = dir.join("hosts"); + let argv = harness_argv(&fifo); + // As the daemon sends it (`hub/hosts.rs`): its class decided there. + let spec: SpawnSpec = serde_json::from_value(json!({ + "session_id": "s-cpu-h", + "program": argv[0], + "args": argv[1..], + // The stand-in's environment: never the host's (its PATH has real agents). + "env": daemon_env::vars(&dir, daemon_env::Agents::StandIn, &[]), + "cwd": dir, + "translator": null, + "socket": agent_host::socket_path(&hosts, "s-cpu-h"), + "hosts_dir": hosts, + "buffer_cap": agent_host::DEFAULT_BUFFER_CAP, + "cpu_class": "background", + })) + .unwrap(); + let launcher = HostLauncher { exe: daemon_env::bin().into(), prefix: Vec::new() }; + let record = link::spawn(&launcher, &spec).await.expect("spawn the host"); + let (harness, sleeper) = read_pids(fifo).await; + assert_eq!(record.harness_pid, Some(harness as u32)); + let (h, s) = (base_priority(harness), base_priority(sleeper)); + end_group(harness); + assert_eq!(h, BACKGROUND_PRI, "the hosted harness runs at the GUI's class"); + assert_eq!(s, BACKGROUND_PRI, "a tool the hosted harness started runs at the GUI's class"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/cmux-tui/crates/acpmux/tests/cwd_pin.rs b/cmux-tui/crates/acpmux/tests/cwd_pin.rs index c2be8a8d76ab..cc7027f18785 100644 --- a/cmux-tui/crates/acpmux/tests/cwd_pin.rs +++ b/cmux-tui/crates/acpmux/tests/cwd_pin.rs @@ -99,6 +99,7 @@ async fn a_pinned_hosted_harness_never_execs_in_a_swapped_folder() { buffer_cap: agent_host::DEFAULT_BUFFER_CAP, router_owner_fifo: None, cwd_pin: Some(pin), + cpu_class: Default::default(), }; let launcher = HostLauncher { exe: daemon_env::bin().into(), prefix: Vec::new() }; let started = link::spawn(&launcher, &spec).await;