From 2b4a97f07806ad04eada8d440b79dba2e3df5436 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 13:20:19 -0700 Subject: [PATCH 1/5] test(acpmux): a session harness and its tools start at background CPU class on macOS (red) Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a --- cmux-tui/crates/acpmux/tests/cpu_class.rs | 163 ++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 cmux-tui/crates/acpmux/tests/cpu_class.rs 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..d92f41ae7809 --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/cpu_class.rs @@ -0,0 +1,163 @@ +//! 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/). +const HERMETIC: daemon_env::Process = daemon_env::Process::stand_in(); + +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. +async fn read_pids(fifo: PathBuf) -> (i32, i32) { + let line = tokio::time::timeout( + Duration::from_secs(20), + tokio::task::spawn_blocking(move || std::fs::read_to_string(fifo).unwrap()), + ) + .await + .expect("the harness wrote its pids in time") + .unwrap(); + 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_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); +} From e1583c2ee542ab9cf6558a8c09bb0a0092e1af4a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 13:24:45 -0700 Subject: [PATCH 2/5] feat(acpmux): start session harnesses at background CPU class on macOS Agent harnesses and the tools they run competed with cmux and its Chromium renderers at the same priority; under load a dragged pane's page trailed it. Each session's harness now starts with PRIO_DARWIN_BG, set between fork and exec (direct children and agent hosts), so everything it runs inherits it. config session.cpuClass, the daemon's ACPMUX_SESSION_CPU_CLASS and a profile's env pick normal to opt out. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a --- .../acpmux/docs/background-processes.md | 1 + cmux-tui/crates/acpmux/src/agent.rs | 2 + cmux-tui/crates/acpmux/src/agent_host/host.rs | 2 + cmux-tui/crates/acpmux/src/agent_host/mod.rs | 4 + cmux-tui/crates/acpmux/src/cli/command.rs | 3 +- cmux-tui/crates/acpmux/src/config.rs | 27 +++- cmux-tui/crates/acpmux/src/config/tests.rs | 17 +++ cmux-tui/crates/acpmux/src/cpu_class.rs | 121 ++++++++++++++++++ cmux-tui/crates/acpmux/src/hub/hosts.rs | 1 + cmux-tui/crates/acpmux/src/hub/pool/start.rs | 1 + cmux-tui/crates/acpmux/src/lib.rs | 1 + cmux-tui/crates/acpmux/src/session_scope.rs | 2 +- cmux-tui/crates/acpmux/tests/agent_host.rs | 1 + cmux-tui/crates/acpmux/tests/cwd_pin.rs | 1 + 14 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 cmux-tui/crates/acpmux/src/cpu_class.rs 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 1211cc917245..b67735b69dac 100644 --- a/cmux-tui/crates/acpmux/src/agent.rs +++ b/cmux-tui/crates/acpmux/src/agent.rs @@ -710,6 +710,8 @@ 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`). + crate::cpu_class::CpuClass::for_session(&profile.env).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 d2b3401c6dc2..a063ece07aee 100644 --- a/cmux-tui/crates/acpmux/src/cli/command.rs +++ b/cmux-tui/crates/acpmux/src/cli/command.rs @@ -534,7 +534,8 @@ pub enum Command { #[arg(long)] show_token: bool, /// Set a daemon host-wide setting as KEY=VALUE (for example, - /// `session.memory_mb=6144` or `session.swap_mb=2048`). + /// `session.memory_mb=6144`, `session.swap_mb=2048` 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 75cb4f1e5cc0..ef8e9a581790 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 { @@ -832,6 +833,11 @@ pub struct SessionResourceConfig { /// Per-session swap ceiling in MiB. Unset uses one quarter of host swap. #[serde(default, skip_serializing_if = "Option::is_none")] pub swap_mb: 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 { @@ -1078,6 +1084,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 @@ -1086,7 +1105,7 @@ impl Config { &mut self.session.swap_mb } _ => anyhow::bail!( - "unknown session resource setting `{key}` (use session.memory_mb or session.swap_mb)" + "unknown session resource setting `{key}` (use session.memory_mb, session.swap_mb or session.cpu_class)" ), }; let value = value.trim(); diff --git a/cmux-tui/crates/acpmux/src/config/tests.rs b/cmux-tui/crates/acpmux/src/config/tests.rs index ef3e3fe33f6a..c7b7c7c223ba 100644 --- a/cmux-tui/crates/acpmux/src/config/tests.rs +++ b/cmux-tui/crates/acpmux/src/config/tests.rs @@ -12,6 +12,23 @@ fn cli_session_resource_settings_are_config_values() { assert_eq!(config.session.memory_mb, None); } +#[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..dddb986a00b4 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/cpu_class.rs @@ -0,0 +1,121 @@ +//! 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` (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}; +use std::collections::BTreeMap; + +/// 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 profile env is `profile_env`, with the daemon's config read + /// now (it is read at each spawn, as the session scope's limits are). + #[cfg(target_os = "macos")] + pub fn for_session(profile_env: &BTreeMap) -> Self { + let config = crate::config::Config::load().ok().and_then(|c| c.session.cpu_class); + Self::resolve(profile_env.get(ENV).map(String::as_str), std::env::var(ENV).ok(), config) + } + + /// Off macOS: `normal`, with nothing read. + #[cfg(not(target_os = "macos"))] + pub fn for_session(_profile_env: &BTreeMap) -> 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/hosts.rs b/cmux-tui/crates/acpmux/src/hub/hosts.rs index efceeb1dcc7a..a2b0828dc58c 100644 --- a/cmux-tui/crates/acpmux/src/hub/hosts.rs +++ b/cmux-tui/crates/acpmux/src/hub/hosts.rs @@ -1619,6 +1619,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: crate::cpu_class::CpuClass::for_session(&profile.env), }; 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..21a26b76934f 100644 --- a/cmux-tui/crates/acpmux/src/hub/pool/start.rs +++ b/cmux-tui/crates/acpmux/src/hub/pool/start.rs @@ -112,6 +112,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: crate::cpu_class::CpuClass::for_session(&profile.env), }; // 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 cb443aec829d..7547cfa02a04 100644 --- a/cmux-tui/crates/acpmux/src/lib.rs +++ b/cmux-tui/crates/acpmux/src/lib.rs @@ -41,6 +41,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 2de273a8d2ae..8d84b321bec9 100644 --- a/cmux-tui/crates/acpmux/src/session_scope.rs +++ b/cmux-tui/crates/acpmux/src/session_scope.rs @@ -27,7 +27,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/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; From 8a0720b2dd7246c5a44ee4a00f12980a3a9a097b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 13:53:45 -0700 Subject: [PATCH 3/5] fix(acpmux): the reload test's session config names its CPU class Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a --- cmux-tui/crates/acpmux/src/hub/catalog_reload.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/acpmux/src/hub/catalog_reload.rs b/cmux-tui/crates/acpmux/src/hub/catalog_reload.rs index 262d4ddf812a..51acbe5f319b 100644 --- a/cmux-tui/crates/acpmux/src/hub/catalog_reload.rs +++ b/cmux-tui/crates/acpmux/src/hub/catalog_reload.rs @@ -124,7 +124,11 @@ mod tests { *hub.probe_only.lock().unwrap() = Some(Default::default()); for expected in [ - SessionResourceConfig { memory_mb: Some(6144), swap_mb: Some(2048) }, + SessionResourceConfig { + memory_mb: Some(6144), + swap_mb: Some(2048), + cpu_class: Some(crate::cpu_class::CpuClass::Normal), + }, SessionResourceConfig::default(), ] { let mut changed = Config::load_from_with(&path, &ProfileSources::none()).unwrap(); From 11bc6188cac948c10b6be4e11afcdbf27b10c3be Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 14:29:42 -0700 Subject: [PATCH 4/5] fix(acpmux): read the CPU class from the harness's resolved env; a pid wait the test can release Review: a profile's ${env:...} reference that resolves to normal was read unresolved, so the harness started at background class anyway. The class is now read from the command's own environment, as the harness gets it. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a --- cmux-tui/crates/acpmux/src/agent.rs | 5 ++- cmux-tui/crates/acpmux/src/cpu_class.rs | 18 +++++----- cmux-tui/crates/acpmux/src/hub/hosts.rs | 17 +++++---- cmux-tui/crates/acpmux/src/hub/pool/start.rs | 17 +++++---- cmux-tui/crates/acpmux/tests/cpu_class.rs | 36 +++++++++++++++----- 5 files changed, 64 insertions(+), 29 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/agent.rs b/cmux-tui/crates/acpmux/src/agent.rs index b67735b69dac..bee92ce3e668 100644 --- a/cmux-tui/crates/acpmux/src/agent.rs +++ b/cmux-tui/crates/acpmux/src/agent.rs @@ -711,7 +711,10 @@ impl ChildAgent { #[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`). - crate::cpu_class::CpuClass::for_session(&profile.env).apply(&mut cmd); + // 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/cpu_class.rs b/cmux-tui/crates/acpmux/src/cpu_class.rs index dddb986a00b4..a4211e9e654a 100644 --- a/cmux-tui/crates/acpmux/src/cpu_class.rs +++ b/cmux-tui/crates/acpmux/src/cpu_class.rs @@ -9,14 +9,14 @@ //! 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` (one build-heavy -//! profile can opt out), the daemon's env `ACPMUX_SESSION_CPU_CLASS`, config `session.cpuClass` +//! 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}; -use std::collections::BTreeMap; /// The knob, in a profile's env or the daemon's. pub const ENV: &str = "ACPMUX_SESSION_CPU_CLASS"; @@ -45,17 +45,19 @@ impl CpuClass { } } - /// The class of a session whose profile env is `profile_env`, with the daemon's config read - /// now (it is read at each spawn, as the session scope's limits are). + /// 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(profile_env: &BTreeMap) -> Self { + 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_env.get(ENV).map(String::as_str), std::env::var(ENV).ok(), config) + Self::resolve(profile, std::env::var(ENV).ok(), config) } /// Off macOS: `normal`, with nothing read. #[cfg(not(target_os = "macos"))] - pub fn for_session(_profile_env: &BTreeMap) -> Self { + pub fn for_session<'a>(_harness_env: impl IntoIterator) -> Self { Self::Normal } diff --git a/cmux-tui/crates/acpmux/src/hub/hosts.rs b/cmux-tui/crates/acpmux/src/hub/hosts.rs index a2b0828dc58c..035e02c100ca 100644 --- a/cmux-tui/crates/acpmux/src/hub/hosts.rs +++ b/cmux-tui/crates/acpmux/src/hub/hosts.rs @@ -1602,15 +1602,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, @@ -1619,7 +1624,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: crate::cpu_class::CpuClass::for_session(&profile.env), + 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 21a26b76934f..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,7 +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: crate::cpu_class::CpuClass::for_session(&profile.env), + 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/tests/cpu_class.rs b/cmux-tui/crates/acpmux/tests/cpu_class.rs index d92f41ae7809..3d7b7659c245 100644 --- a/cmux-tui/crates/acpmux/tests/cpu_class.rs +++ b/cmux-tui/crates/acpmux/tests/cpu_class.rs @@ -55,15 +55,22 @@ fn harness_argv(fifo: &Path) -> Vec { vec!["/bin/sh".into(), "-c".into(), script] } -/// The harness's and the sleeper's pids, once the harness wrote them. +/// 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 line = tokio::time::timeout( - Duration::from_secs(20), - tokio::task::spawn_blocking(move || std::fs::read_to_string(fifo).unwrap()), - ) - .await - .expect("the harness wrote its pids in time") - .unwrap(); + 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()) } @@ -130,6 +137,19 @@ async fn cpu_class_a_profile_can_keep_its_harness_at_normal_class() { 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"); + // SAFETY: this test process's only use of the variable; set before the spawn reads it. + unsafe { std::env::set_var("ACPMUX_TEST_CPU_CLASS_REF", "normal") }; + 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"); From abd6b68ae24756bd9c30048fe83c1a329bbe54d1 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 14:51:12 -0700 Subject: [PATCH 5/5] test(acpmux): the reference test's variable comes from the hermetic process env, not set_var Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-1288bbb2b5d2f5c6b66a --- cmux-tui/crates/acpmux/tests/cpu_class.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cmux-tui/crates/acpmux/tests/cpu_class.rs b/cmux-tui/crates/acpmux/tests/cpu_class.rs index 3d7b7659c245..92ee79a29d1b 100644 --- a/cmux-tui/crates/acpmux/tests/cpu_class.rs +++ b/cmux-tui/crates/acpmux/tests/cpu_class.rs @@ -11,8 +11,10 @@ #[path = "support/daemon_env.rs"] mod daemon_env; -// This process runs stand-in agents (the fake agents under tests/). -const HERMETIC: daemon_env::Process = daemon_env::Process::stand_in(); +// 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}; @@ -140,8 +142,7 @@ async fn cpu_class_a_profile_can_keep_its_harness_at_normal_class() { #[tokio::test] async fn cpu_class_a_profile_reference_that_resolves_to_normal_keeps_normal_class() { let dir = scratch("r"); - // SAFETY: this test process's only use of the variable; set before the spawn reads it. - unsafe { std::env::set_var("ACPMUX_TEST_CPU_CLASS_REF", "normal") }; + // 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);