From 0d619e215e9d9f113930aa23d7124fd27b4bb214 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 12 Oct 2026 09:00:45 +0200 Subject: [PATCH 1/4] agents: read the memory cap at boot, not from the build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent's memory cap was rendered into its flake (`memoryMaxBytes` → `services.hyperhive.agent.claudeMemoryMaxBytes`), so the closure depended on it and a changed cap reached the JSC heap ceiling and the subagent `MemoryHigh=` only after a rebuild. hive-c0re now writes the effective `MemoryMax=` in bytes to the agent's runtime dir (`/run/hive/memory-max-bytes` inside) in `write_dropins`, from the same value the drop-in gets. The file is removed for `infinity` or a RAM percentage, and left alone when the drop-in is kept. Inside the container, `nix/agent-modules/memory-cap.nix` reads it at boot: an environment generator sets `BUN_JSC_forceRAMSize` (75%) for every unit and `machinectl shell`, `/etc/set-environment` exports it in login shells, and a unit generator writes `MemoryHigh=` (2/3) as a drop-in for `hive-subagent-daemon`. The render drops `memoryMaxBytes`, the agent option is removed (both spellings fail eval with a removal message), and `effective_memory_bytes_from` goes with its only caller. Part of #5081 (slice S0-2). --- docs/agent-lifecycle/persistence.md | 8 +- docs/scheduler/coordinator.md | 8 + hive-c0re/src/agent_config/resource_limits.rs | 96 +----------- hive-c0re/src/lifecycle/host_config.rs | 137 ++++++++++++++++-- hive-c0re/src/meta.rs | 107 ++++---------- hive-c0re/src/server.rs | 7 +- nix/agent-modules/claude-settings.nix | 47 +----- nix/agent-modules/default.nix | 13 ++ nix/agent-modules/mcp.nix | 24 +-- nix/agent-modules/memory-cap.nix | 68 +++++++++ nix/agent-modules/renamed-options.nix | 4 - nix/checks.nix | 6 + nix/module-eval/agent-memory.nix | 42 ++---- nix/script-tests/agent-memory-cap.nix | 42 ++++++ nix/script-tests/agent-memory-cap.sh | 94 ++++++++++++ 15 files changed, 416 insertions(+), 287 deletions(-) create mode 100644 nix/agent-modules/memory-cap.nix create mode 100644 nix/script-tests/agent-memory-cap.nix create mode 100644 nix/script-tests/agent-memory-cap.sh diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 1a5ccbf8..c12d7aab 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -403,7 +403,8 @@ Contents: Written by `resource_limits::set_limits`; read where `lifecycle::write_dropins` generates the systemd drop-in, **not** injected into the container — these are host-side caps on the container, so - the capped party never sees or sets them. Fallback is per *field*: + the capped party never sets them. It sees only the resulting memory cap, + as a byte count in its runtime dir (below). Fallback is per *field*: an absent file, absent agent, or absent field falls back to the hive-wide `services.hyperhive.c0re.agentCpuQuota` / `agentMemoryMax`, so an agent can override only its memory and still track the hive @@ -508,6 +509,11 @@ containers can't start. - `/run/hyperhive/host.sock` — admin socket (host-side CLI). - `/run/hyperhive/agents//mcp.sock` — per-agent socket (bind-mounted into the container as `/run/hive/mcp.sock`). +- `/run/hyperhive/agents//memory-max-bytes` — the agent's effective + `MemoryMax=` in bytes, written by `lifecycle::write_dropins` before every + start (absent when the cap is `infinity` or a RAM percentage). The + container reads it at boot as `/run/hive/memory-max-bytes` + (`nix/agent-modules/memory-cap.nix`). On startup, `Coordinator::register_agent` drops any prior socket task before rebinding — idempotent so a hive-c0re restart followed diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index a98f1f46..3b87bcec 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -452,6 +452,14 @@ regardless. rebuild, so changes take effect on the next lifecycle op without requiring a host rebuild. +The container can't read its own cap, so `write_dropins` also writes the +effective `MemoryMax=` in bytes into the agent's runtime dir +(`/run/hive/memory-max-bytes` inside). At boot the container derives two +values from it: `BUN_JSC_forceRAMSize` (75%, claude-code's heap ceiling) +and `MemoryHigh=` on `hive-subagent-daemon` (two thirds). Neither is in the +agent's build, so a changed cap reaches them at the next container start +without a rebuild. A cap of `infinity` or a RAM percentage sets neither. + The same drop-in carries `CPUWeight=` / `IOWeight=` from `agentCpuWeight` / `agentIoWeight`. Those are a different kind of setting: the quota and the memory max are **hard caps** that throttle diff --git a/hive-c0re/src/agent_config/resource_limits.rs b/hive-c0re/src/agent_config/resource_limits.rs index f449e485..4a173c89 100644 --- a/hive-c0re/src/agent_config/resource_limits.rs +++ b/hive-c0re/src/agent_config/resource_limits.rs @@ -122,55 +122,6 @@ fn resolve(limits: &AgentLimits, hive_cpu_quota: &str, hive_memory_max: &str) -> (cpu, mem) } -/// Effective `MemoryMax=` string for `name` against an already-loaded -/// map — the memory-only half of [`effective_from`], split out because -/// [`effective_memory_bytes_from`] doesn't have a CPU quota to pass. -/// No single-agent convenience wrapper (unlike [`effective`] / -/// [`effective_from`]): every current caller already has a loaded map -/// in hand, so one would just be dead code. -#[must_use] -fn effective_memory_max_from( - limits: &BTreeMap, - name: &str, - hive_memory_max: &str, -) -> String { - limits - .get(name) - .and_then(|l| l.memory_max.clone()) - .unwrap_or_else(|| hive_memory_max.to_owned()) -} - -/// Effective `MemoryMax=` for `name`, as a raw byte count, against an -/// already-loaded map — the form `render_flake_with_lookup` uses so it -/// doesn't re-read `resource-limits.json` once per agent (same -/// reasoning as [`effective_from`] / `container_view::build_all`). No -/// single-agent convenience wrapper, for the same reason as -/// [`effective_memory_max_from`] — every current caller already has a -/// loaded map in hand. -/// -/// Logs a `warn!` naming `name` when the effective value is a RAM -/// percentage: unlike `"infinity"`, a percentage IS a real, resolvable -/// cap — resolving one just needs host `MemTotal`, which this module -/// doesn't track — so today it degrades to `None` (no derived heap -/// ceiling) rather than silently guessing wrong against `MemTotal`. -#[must_use] -pub fn effective_memory_bytes_from( - limits: &BTreeMap, - name: &str, - hive_memory_max: &str, -) -> Option { - let mem = effective_memory_max_from(limits, name, hive_memory_max); - if mem.ends_with('%') { - tracing::warn!( - agent = name, - memory_max = %mem, - "effective MemoryMax= is a RAM percentage; can't derive a JSC heap ceiling from it \ - without host MemTotal — leaving BUN_JSC_forceRAMSize unset for this agent" - ); - } - parse_bytes(&mem) -} - /// Parse a systemd `MemoryMax=`-style byte-size value (`"4G"`, `"512M"`, /// a bare byte count, optionally with a decimal like `"1.5G"`) into a /// raw byte count. systemd's `K`/`M`/`G`/`T` suffixes are IEC binary @@ -179,9 +130,8 @@ pub fn effective_memory_bytes_from( /// value is always a small non-negative decimal (enforced by /// [`is_plain_number`] upstream in [`validate_memory_max`]), so floats /// would only add rounding / sign-loss risk for no benefit. Returns -/// `None` for `"infinity"` and percentages; see -/// [`effective_memory_bytes_from`] for why those can't be turned into a -/// byte count here. +/// `None` for `"infinity"` and for percentages, which need the host's +/// `MemTotal` to resolve. #[must_use] pub fn parse_bytes(value: &str) -> Option { if value == "infinity" || value.ends_with('%') { @@ -532,46 +482,4 @@ mod tests { assert_eq!(parse_bytes(v), None, "{v} should not parse"); } } - - #[test] - fn effective_memory_bytes_falls_back_to_hive_default() { - // Empty map, i.e. no per-agent override — exercises the - // "use the hive-wide default" arm end to end. - let empty = BTreeMap::new(); - assert_eq!( - effective_memory_bytes_from(&empty, "nobody-configured-this-agent", "4G"), - Some(4 * 1024 * 1024 * 1024) - ); - assert_eq!( - effective_memory_bytes_from(&empty, "nobody-configured-this-agent", "infinity"), - None - ); - } - - #[test] - fn effective_memory_bytes_from_prefers_per_agent_override() { - let map = BTreeMap::from([("sock".to_owned(), limits(None, Some("8G")))]); - assert_eq!( - effective_memory_bytes_from(&map, "sock", HIVE_MEM), - Some(8 * 1024 * 1024 * 1024) - ); - // A different agent not in the map still falls back to the - // hive-wide default from the same loaded map (no re-read). - assert_eq!( - effective_memory_bytes_from(&map, "iris", HIVE_MEM), - Some(4 * 1024 * 1024 * 1024) - ); - } - - /// A percentage cap is real and resolvable in principle, but this - /// module has no host `MemTotal` to resolve it against — must - /// degrade to `None` (no derived heap ceiling) rather than silently - /// treating it as unbounded or guessing a number. `warn!` firing is - /// exercised for coverage but not asserted on (no tracing test - /// subscriber wired up here) — the `None` return is the contract. - #[test] - fn effective_memory_bytes_from_returns_none_for_percentage() { - let map = BTreeMap::from([("sock".to_owned(), limits(None, Some("50%")))]); - assert_eq!(effective_memory_bytes_from(&map, "sock", HIVE_MEM), None); - } } diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 2c70fd1a..afb00da5 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -1,6 +1,7 @@ //! Per-container host-side config: the nspawn conf rewrite (bind mounts, //! network isolation, forwarded credentials), the systemd resource-limits -//! drop-in, and the `write_dropins` verb that re-applies both. +//! drop-in, the agent's copy of its memory cap, and the `write_dropins` verb +//! that re-applies them. use std::path::{Path, PathBuf}; @@ -16,20 +17,21 @@ use super::{ }; /// Re-apply the per-container host-side config: nspawn flags (bind -/// mounts etc.), the systemd resource-limits drop-in, and a daemon -/// reload so both take effect on the next unit (re)start. Idempotent — -/// the job queue's `WriteDropin` node, also folded into every `Swap` -/// (rebuild is the reconcile verb). +/// mounts etc.), the systemd resource-limits drop-in, the agent's copy of +/// its memory cap, and a daemon reload so all of it takes effect on the next +/// unit (re)start. Idempotent — the job queue's `WriteDropin` node, also +/// folded into every `Swap` (rebuild is the reconcile verb). pub async fn write_dropins(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Result<()> { validate(name)?; let container = container_name(name); set_nspawn_flags(&container, &paths.agent, &paths.claude, &paths.notes).await?; - if let Some((cpu_quota, memory_max)) = limits_to_apply( + if let Some((cpu_quota, memory_max)) = stage_limits( name, &crate::resource_limits::resource_limits_path(), &limits_dropin_path(&container), + &paths.agent, hive, - ) { + )? { set_resource_limits( &container, &cpu_quota, @@ -51,6 +53,60 @@ fn limits_dropin_path(container: &str) -> PathBuf { )) } +/// File in the agent's runtime dir, `/run/hive/` in the container, holding the +/// byte count of the `MemoryMax=` its drop-in carries. +/// `nix/agent-modules/memory-cap.nix` reads it at boot under this name. +const MEMORY_CAP_FILE: &str = "memory-max-bytes"; + +/// [`limits_to_apply`], plus handing the memory cap to the agent through +/// [`MEMORY_CAP_FILE`] in `runtime_dir`. The container cannot read the cap +/// systemd enforces on it, so this file is how it learns the value it sizes +/// its heap and its subagents against. When the drop-in is kept as it is, so is +/// the file. +fn stage_limits( + agent_name: &str, + limits_path: &Path, + dropin_path: &Path, + runtime_dir: &Path, + hive: &HiveEnv, +) -> Result> { + let limits = limits_to_apply(agent_name, limits_path, dropin_path, hive); + if let Some((_, memory_max)) = &limits { + write_memory_cap(runtime_dir, agent_name, memory_max)?; + } + Ok(limits) +} + +/// Write `memory_max` into [`MEMORY_CAP_FILE`] as a byte count, or remove the +/// file when the value has none: `infinity`, or a RAM percentage, which needs +/// the host's `MemTotal` to resolve. With no file the agent sets no heap or +/// subagent ceiling. +fn write_memory_cap(runtime_dir: &Path, agent_name: &str, memory_max: &str) -> Result<()> { + let path = runtime_dir.join(MEMORY_CAP_FILE); + let Some(bytes) = crate::resource_limits::parse_bytes(memory_max) else { + if memory_max.ends_with('%') { + tracing::warn!( + agent = agent_name, + memory_max, + "effective MemoryMax= is a RAM percentage; the agent gets no byte count, so \ + no JSC heap ceiling and no subagent MemoryHigh=" + ); + } + return match std::fs::remove_file(&path) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + Err(e).with_context(|| format!("remove {}", path.display())) + } + _ => Ok(()), + }; + }; + std::fs::create_dir_all(runtime_dir) + .with_context(|| format!("create agent runtime dir {}", runtime_dir.display()))?; + let tmp = runtime_dir.join(format!(".{MEMORY_CAP_FILE}.tmp")); + std::fs::write(&tmp, format!("{bytes}\n")) + .with_context(|| format!("write {}", tmp.display()))?; + std::fs::rename(&tmp, &path).with_context(|| format!("rename onto {}", path.display())) +} + /// The `(CPUQuota, MemoryMax)` to write into `agent_name`'s limits /// drop-in, or `None` to leave the drop-in at `dropin_path` as it is. /// @@ -489,8 +545,9 @@ async fn set_nspawn_flags( #[cfg(test)] mod tests { use super::{ - BindMount, QUEUE_CLIENT_ID_CREDENTIAL, QUEUE_SECRET_CREDENTIAL, bind_child_agent_dirs, - bind_own_agent_dirs, holds_manage_root_agent, limits_to_apply, queue_agent_credentials, + BindMount, MEMORY_CAP_FILE, QUEUE_CLIENT_ID_CREDENTIAL, QUEUE_SECRET_CREDENTIAL, + bind_child_agent_dirs, bind_own_agent_dirs, holds_manage_root_agent, limits_to_apply, + queue_agent_credentials, stage_limits, }; use crate::coordinator::HiveEnv; @@ -707,4 +764,66 @@ mod tests { let applied = limits_to_apply("sock", &limits, &dropin, &HiveEnv::default()); assert_eq!(applied, Some(("200%".to_owned(), "1G".to_owned()))); } + + /// Runs [`stage_limits`] against the files in `dir` and returns the + /// drop-in's `MemoryMax=` and the agent's copy of it, `None` when absent. + fn stage(dir: &tempfile::TempDir) -> (Option, Option) { + let runtime = dir.path().join("runtime"); + let applied = stage_limits( + "sock", + &dir.path().join("resource-limits.json"), + &dir.path().join("hyperhive-limits.conf"), + &runtime, + &HiveEnv::default(), + ) + .expect("stage limits"); + let copy = std::fs::read_to_string(runtime.join(MEMORY_CAP_FILE)).ok(); + (applied.map(|(_, mem)| mem), copy) + } + + #[test] + fn the_agent_reads_the_cap_its_dropin_carries() { + let dir = tempfile::tempdir().expect("tempdir"); + let limits = dir.path().join("resource-limits.json"); + + // No overrides anywhere: the hive-wide default, 4G. + assert_eq!( + stage(&dir), + (Some("4G".to_owned()), Some("4294967296\n".to_owned())) + ); + + std::fs::write(&limits, r#"{"sock": {"memory_max": "16G"}}"#).expect("seed limits"); + assert_eq!( + stage(&dir), + (Some("16G".to_owned()), Some("17179869184\n".to_owned())) + ); + } + + #[test] + fn a_cap_with_no_byte_count_removes_the_agents_copy() { + let dir = tempfile::tempdir().expect("tempdir"); + let limits = dir.path().join("resource-limits.json"); + assert_eq!(stage(&dir).1, Some("4294967296\n".to_owned())); + + std::fs::write(&limits, r#"{"sock": {"memory_max": "infinity"}}"#).expect("seed limits"); + assert_eq!(stage(&dir), (Some("infinity".to_owned()), None)); + + assert_eq!(stage(&dir).1, None, "an absent copy stays absent"); + + std::fs::write(&limits, r#"{"sock": {"memory_max": "50%"}}"#).expect("seed limits"); + assert_eq!(stage(&dir), (Some("50%".to_owned()), None)); + } + + #[test] + fn a_kept_dropin_keeps_the_agents_copy() { + let dir = tempfile::tempdir().expect("tempdir"); + let limits = dir.path().join("resource-limits.json"); + std::fs::write(&limits, r#"{"sock": {"memory_max": "1G"}}"#).expect("seed limits"); + assert_eq!(stage(&dir).1, Some("1073741824\n".to_owned())); + + std::fs::write(&limits, TRUNCATED_LIMITS).expect("seed limits"); + std::fs::write(dir.path().join("hyperhive-limits.conf"), "MemoryMax=1G\n") + .expect("seed drop-in"); + assert_eq!(stage(&dir), (None, Some("1073741824\n".to_owned()))); + } } diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 917a85e1..1c8f88d6 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -88,17 +88,7 @@ pub async fn sync_agents(hive: &HiveEnv, agents: &[AgentSpec]) -> Result<()> { let dir = crate::paths::meta_root(); std::fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; - let new_flake = render_flake( - &hive.hyperhive_flake, - &hive.hyperhive_docs_flake, - &hive.nixpkgs_flake, - hive.claude_code_path.as_deref(), - hive.dashboard_port, - &hive.operator_pronouns, - &hive.context_window_tokens, - &hive.agent_memory_max, - agents, - )?; + let new_flake = render_hive_flake(hive, agents)?; let flake_path = dir.join("flake.nix"); let on_disk = std::fs::read_to_string(&flake_path).unwrap_or_default(); let initial = !dir.join(".git").exists(); @@ -444,6 +434,21 @@ pub async fn commit_perms( Ok(()) } +/// [`render_flake`] with its inputs taken from `hive`, as [`sync_agents`] +/// renders it. +fn render_hive_flake(hive: &HiveEnv, agents: &[AgentSpec]) -> Result { + render_flake( + &hive.hyperhive_flake, + &hive.hyperhive_docs_flake, + &hive.nixpkgs_flake, + hive.claude_code_path.as_deref(), + hive.dashboard_port, + &hive.operator_pronouns, + &hive.context_window_tokens, + agents, + ) +} + #[allow( clippy::too_many_arguments, reason = "many genuine flake inputs (source flakes, port, pronouns, tokens, \ @@ -457,7 +462,6 @@ fn render_flake( dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, - hive_memory_max: &str, agents: &[AgentSpec], ) -> Result { render_flake_with_lookup( @@ -468,7 +472,6 @@ fn render_flake( dashboard_port, operator_pronouns, context_window_tokens, - hive_memory_max, agents, agent_canonical_inputs, ) @@ -861,7 +864,6 @@ fn render_flake_with_lookup( dashboard_port: u16, operator_pronouns: &str, context_window_tokens: &std::collections::HashMap, - hive_memory_max: &str, agents: &[AgentSpec], lookup: F, ) -> Result @@ -943,7 +945,7 @@ where let pronouns_escaped = operator_pronouns.replace('\\', "\\\\").replace('"', "\\\""); let _ = writeln!( out, - " dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null, capabilities ? null, memoryMaxBytes ? null }}:" + " dashboardPort = {dashboard_port};\n operatorPronouns = \"{pronouns_escaped}\";\n mkAgent = {{ name, isManager, port, toolGroups ? null, capabilities ? null }}:" ); out.push_str( r#" let @@ -1094,7 +1096,6 @@ where # nspawn itself already call this container, so logs join to # both without a prefix transform. networking.hostName = "h-${name}"; - services.hyperhive.agent.claudeMemoryMaxBytes = memoryMaxBytes; programs.git.config.user = { name = name; email = "${name}@hyperhive.local"; @@ -1177,12 +1178,9 @@ where ); // Propagated, never read as empty: an agent with no tool-groups entry // renders `toolGroups = null` and gets `AGENT_DEFAULT`, which fails - // open for any agent whose explicit entry is narrower. Likewise an - // agent with no resource-limits entry gets the hive `memoryMaxBytes`, - // a larger heap ceiling than a tighter override. + // open for any agent whose explicit entry is narrower. let tool_groups_map = crate::tool_groups::read()?; let capabilities_map = crate::capabilities::read()?; - let resource_limits_map = crate::resource_limits::read()?; for spec in agents { // Emit `toolGroups = "group1,group2"` when the operator has // explicitly configured groups for this agent. Absent entry = null @@ -1208,29 +1206,15 @@ where let joined = caps.join(","); format!("\"{joined}\"") }; - // Effective `MemoryMax=` for this agent (per-agent override, else - // the hive-wide default), turned into a raw byte count so - // the agent's claude-settings module can derive a JSC heap ceiling - // (see `services.hyperhive.agent.claudeMemoryMaxBytes`). `null` when the - // effective value is `"infinity"` or a RAM percentage — no - // byte count to derive, dependent env var stays unset, same as - // today's no-cap behavior. - let memory_max_attr = crate::resource_limits::effective_memory_bytes_from( - &resource_limits_map, - &spec.name, - hive_memory_max, - ) - .map_or_else(|| "null".to_owned(), |b| b.to_string()); let _ = writeln!( out, - " {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; capabilities = {}; memoryMaxBytes = {}; }};", + " {} = mkAgent {{ name = \"{}\"; isManager = {}; port = {}; toolGroups = {}; capabilities = {}; }};", spec.name, spec.name, if spec.is_manager { "true" } else { "false" }, spec.port, tool_groups_attr, capabilities_attr, - memory_max_attr, ); } out.push_str(" };\n };\n}\n"); @@ -1468,7 +1452,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake"); @@ -1516,7 +1499,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake"); @@ -1536,7 +1518,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake"); @@ -1578,7 +1559,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake"); @@ -1608,7 +1588,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake"); @@ -1630,7 +1609,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake"); @@ -1662,7 +1640,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[ sample_spec("argus", false, 9001), sample_spec("bitburner", false, 9002), @@ -1697,7 +1674,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], |_| Vec::new(), ) @@ -1737,7 +1713,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake"); @@ -1784,7 +1759,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake") @@ -1966,7 +1940,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake"); @@ -2004,7 +1977,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake"); @@ -2050,7 +2022,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake") @@ -2144,7 +2115,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake") @@ -2220,7 +2190,6 @@ mod tests { 8000, "she/her", &std::collections::HashMap::new(), - "4G", &[sample_spec("alice", false, 9001)], ) .expect("render flake") @@ -2246,32 +2215,20 @@ mod tests { ); } - /// The JSC-heap-ceiling fix needs the effective per-agent memory cap - /// threaded into the flake as raw bytes, since nspawn hides the real - /// cgroup cap from inside the container. An - /// agent with no `resource-limits.json` override falls back to the - /// hive-wide default passed to `render_flake` (`"4G"` in every test - /// in this module) — this locks in the byte-count conversion end to - /// end through the actual render path (not just `parse_bytes` - /// in isolation). + /// The agent reads its memory cap at boot from the copy + /// `lifecycle::write_dropins` hands it, so the cap is no build input. #[test] - fn render_flake_derives_memory_max_bytes_from_hive_default() { - let out = render_flake( - "github:example/hyperhive", - "path:/nix/store/bbbb-hyperhive-docs-source", - "path:/nix/store/aaaa-nixpkgs-source", - None, - 8000, - "she/her", - &std::collections::HashMap::new(), - "4G", - &[sample_spec("alice", false, 9001)], - ) - .expect("render flake"); - let want_bytes = 4u64 * 1024 * 1024 * 1024; - assert!( - out.contains(&format!("memoryMaxBytes = {want_bytes};")), - "memoryMaxBytes must reflect the hive-wide default in bytes:\n{out}" + fn render_hive_flake_is_identical_across_memory_caps() { + let hive = |cap: &str| HiveEnv { + agent_memory_max: cap.to_owned(), + ..HiveEnv::default() + }; + let agents = [sample_spec("alice", false, 9001)]; + let small = render_hive_flake(&hive("4G"), &agents).expect("render 4G"); + let large = render_hive_flake(&hive("16G"), &agents).expect("render 16G"); + assert_eq!( + small, large, + "the rendered flake must not depend on the memory cap" ); } } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index de085d53..5d9839bc 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -433,10 +433,9 @@ async fn handle_set_resource_limits( &hive.agent_memory_max, )?; Ok(HostResponse::messages(vec![format!( - "{name}: CPUQuota={cpu} MemoryMax={mem} — the cgroup cap itself is live now (restart \ - the container if it's running and needs the new cap immediately), but the derived \ - Claude/JSC heap ceiling is baked in at build time, so it needs a REBUILD \ - (`hivectl agent {name} rebuild`) to actually track this change" + "{name}: CPUQuota={cpu} MemoryMax={mem} — the cgroup cap itself is live now; the \ + Claude/JSC heap ceiling and the subagent MemoryHigh= derived from it follow at the \ + container's next start (restart it if it's running and needs them now)" )])) } diff --git a/nix/agent-modules/claude-settings.nix b/nix/agent-modules/claude-settings.nix index 0322bc8e..944ef489 100644 --- a/nix/agent-modules/claude-settings.nix +++ b/nix/agent-modules/claude-settings.nix @@ -19,11 +19,6 @@ let # meta.rs `otel_config`). Options declared in `otel.nix`, which also # exports the generic OTLP environment this container's producers read. otelCfg = config.services.hyperhive.agent.otel; - # Effective per-agent MemoryMax=, in bytes, injected by meta.rs's - # per-agent flake render (`services.hyperhive.agent.claudeMemoryMaxBytes`). `null` - # when the effective cap is unbounded ("infinity") or a RAM - # percentage — see `resource_limits::effective_memory_bytes`. - memoryMaxBytes = config.services.hyperhive.agent.claudeMemoryMaxBytes; # Base claude-code environment applied to every agent regardless of OTEL. # Shipped via the managed settings `env` block so claude and `hivectl # choom` both inherit them without a launch wrapper. @@ -47,19 +42,6 @@ let CLAUDE_CODE_RESUME_INTERRUPTED_TURN = "1"; # Use the simpler system prompt variant suited to headless operation. CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT = "1"; - } - # Bun/JavaScriptCore (the runtime under claude-code's `.claude-wrapped` - # binary) sizes its default heap ceiling off the container's visible - # `/proc/meminfo`, which under nspawn is the *host's* physical RAM, not - # the systemd `MemoryMax=` cgroup cap actually enforced on this - # container — the cap lives on the host's outer cgroup and is invisible - # from inside (confirmed: `/sys/fs/cgroup/memory.max` reads `max` at - # every level from in here). That mismatch lets JSC's heap grow well - # past the real wall before GC kicks in hard, causing choom sessions to - # hang. Pin JSC's ceiling to 75% of the *actual* effective cap instead, - # once it's known at build time. - // lib.optionalAttrs (memoryMaxBytes != null) { - BUN_JSC_forceRAMSize = toString (memoryMaxBytes * 75 / 100); }; # Claude Code's own telemetry switches — what it emits, and whether it # emits at all. Everything an OTEL SDK reads generically (endpoint, @@ -99,31 +81,6 @@ in ''; }; - # Build-time implementation surface for the JSC-heap-ceiling fix: - # meta.rs's per-agent flake render injects this from the effective - # `MemoryMax=` (per-agent `resource-limits.json` override, else the - # hive-wide `services.hyperhive.agentMemoryMax`) — see - # `resource_limits::effective_memory_bytes_from`. Not meant to be set - # directly in an agent.nix, same convention as `services.hyperhive.agent.otel.*` - # above; the host option (or `hivectl agent set-limits`) is - # the real operator knob, and this only reflects the value baked in at - # the agent's *last rebuild* — `set-limits` still applies the - # cgroup cap live via a drop-in reload, but this derived heap ceiling - # needs a rebuild to pick up a new value. - options.services.hyperhive.agent.claudeMemoryMaxBytes = lib.mkOption { - type = lib.types.nullOr lib.types.ints.positive; - default = null; - internal = true; - description = '' - Effective per-agent memory cap in bytes, when it's a plain - byte-size value (null for an unbounded or percentage-based cap). - This is the whole container's cap, not claude's share of it — - the name records its first consumer, `BUN_JSC_forceRAMSize` in - `baseClaudeEnv`. `mcp.nix` reads it too, to size the subagent - daemon's `MemoryHigh=` against the container it runs in. - ''; - }; - options.services.hyperhive.agent.claudeMarketplaces = lib.mkOption { type = lib.types.listOf lib.types.str; default = [ @@ -257,9 +214,7 @@ in environment.etc."claude-code/managed-settings.json".source = let baseSettings = "${config.services.hyperhive.agent.packages.assets}/share/hyperhive/prompts/claude-settings.json"; - # Merge base env (always) with OTEL env (when enabled). jq is always - # run — `baseClaudeEnv` can carry per-agent values (e.g. - # BUN_JSC_forceRAMSize) that can't live in the static store asset. + # Merge base env (always) with OTEL env (when enabled). allEnv = baseClaudeEnv // lib.optionalAttrs otelCfg.enable otelSettingsEnv diff --git a/nix/agent-modules/default.nix b/nix/agent-modules/default.nix index ff833705..1f5a9197 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -41,6 +41,11 @@ let which the hive writes into every container, so no agent build sets it. Remove the setting. ''; + memoryMaxBytesRemoved = '' + An agent reads its memory cap at boot from /run/hive/memory-max-bytes, + which the hive writes before every start, so no agent build sets it. + Remove the setting. + ''; in { imports = [ @@ -60,6 +65,7 @@ in ./logs.nix ./matrix.nix ./mcp.nix + ./memory-cap.nix ./network.nix ./otel.nix ./packages.nix @@ -94,6 +100,13 @@ in # Both spellings, for the reason given above. (lib.mkRemovedOptionModule [ "hyperhive" "hiveName" ] hiveNameRemoved) (lib.mkRemovedOptionModule [ "services" "hyperhive" "agent" "hiveName" ] hiveNameRemoved) + (lib.mkRemovedOptionModule [ "hyperhive" "claudeMemoryMaxBytes" ] memoryMaxBytesRemoved) + (lib.mkRemovedOptionModule [ + "services" + "hyperhive" + "agent" + "claudeMemoryMaxBytes" + ] memoryMaxBytesRemoved) ]; options.services.hyperhive.agent.icon = lib.mkOption { diff --git a/nix/agent-modules/mcp.nix b/nix/agent-modules/mcp.nix index 9611f73f..d8b04f75 100644 --- a/nix/agent-modules/mcp.nix +++ b/nix/agent-modules/mcp.nix @@ -11,25 +11,6 @@ }: let userName = config.services.hyperhive.agent.user.name; - # This container's own effective `MemoryMax=` in bytes, baked in per - # agent by meta.rs's flake render — see - # `services.hyperhive.agent.claudeMemoryMaxBytes` in ./claude-settings.nix. `null` - # when the cap is `infinity` or a RAM percentage, i.e. when the module - # has no byte count to size anything against. - containerMemoryMaxBytes = config.services.hyperhive.agent.claudeMemoryMaxBytes; - # Two thirds of the container's cap, as the soft ceiling on everything - # the subagent daemon runs. The daemon spawns nested `claude` sessions - # as plain children, so its cgroup already *is* the "all subagents" - # cgroup and a unit-level ceiling bounds the set without a slice. - # - # `MemoryHigh=` and not `MemoryMax=`: this throttles rather than walls. - # Past it the kernel reclaims aggressively and the cgroup stalls, so a - # subagent that overshoots gets visibly slow and the remaining third - # stays available for the agent's own turn — but a subagent that - # genuinely needs more than two thirds still gets it when the container - # has the memory to spare, which is what keeps overprovisioning - # (several agents that rarely compile at the same time) working. - subagentMemoryHigh = containerMemoryMaxBytes * 2 / 3; # Set on the harness only for an ACP agent on the opencode preset # (./agent-service.nix). acpApiKeyEnv = config.systemd.services.hive-agent.environment.HIVE_ACP_API_KEY_ENV or null; @@ -475,12 +456,11 @@ in # actually chose and leaves the daemon serving, which is also what # lets it report the kill rather than vanish and be restarted. OOMPolicy = "continue"; + # `MemoryHigh=` is a drop-in from ./memory-cap.nix, sized at boot from + # the container's cap. User = userName; Group = userName; } - // lib.optionalAttrs (containerMemoryMaxBytes != null) { - MemoryHigh = toString subagentMemoryHigh; - } // lib.optionalAttrs subagentReadsStore { # Bare id: inherits the credential the container manager passed in, # the same form ./queue-identity.nix uses. diff --git a/nix/agent-modules/memory-cap.nix b/nix/agent-modules/memory-cap.nix new file mode 100644 index 00000000..47c29968 --- /dev/null +++ b/nix/agent-modules/memory-cap.nix @@ -0,0 +1,68 @@ +# The container's memory cap, read at boot rather than built in, so a changed +# cap needs no rebuild. +# +# The cap is `MemoryMax=` on the host's `container@` unit, which nothing in +# here can read: `/sys/fs/cgroup/memory.max` reads `max` at every level from +# inside. hive-c0re writes its byte count to `/run/hive/memory-max-bytes` +# before every start (`lifecycle::host_config`), and removes the file when the +# cap is `infinity` or a RAM percentage. Two values are derived from it, and +# neither is set without the file: +# +# - `BUN_JSC_forceRAMSize`, 75% of the cap. Bun/JavaScriptCore, the runtime +# under claude-code, sizes its heap ceiling off `/proc/meminfo`, which under +# nspawn is the host's RAM, so without it the heap grows well past the real +# wall before GC kicks in hard and choom sessions hang. An environment +# generator hands it to every unit of the container's systemd, `machinectl +# shell` sessions (`hivectl choom`) included; `/etc/set-environment` exports +# it in login shells that no unit starts (`nixos-container root-login`). +# - `MemoryHigh=` on `hive-subagent-daemon`, two thirds of the cap, as a +# generator drop-in. The daemon spawns nested `claude` sessions as plain +# children, so its cgroup already is the "all subagents" cgroup. `High`, not +# `Max`: past it the kernel reclaims hard and the subagents slow down, so the +# last third stays free for the agent's own turn, but a subagent that needs +# more still gets it when the container has memory to spare. +# +# Tested by `checks.script-test-agent-memory-cap`. +{ pkgs, ... }: +let + # Sets `bytes`, or exits when there is no file or its first line is not a + # plain byte count. A leading zero would read as octal, and 16 digits or + # more could overflow `bytes * 75`. Shell builtins only. + readCap = '' + bytes= + [ -r /run/hive/memory-max-bytes ] && read -r bytes < /run/hive/memory-max-bytes + case "$bytes" in + "" | 0* | *[!0-9]* | ????????????????*) exit 0 ;; + esac + ''; + + envGenerator = pkgs.writeShellScript "hyperhive-memory-cap-env" '' + ${readCap} + printf 'BUN_JSC_forceRAMSize=%s\n' "$((bytes * 75 / 100))" + ''; + + # `$1` is the generator's normal-priority output dir. A drop-in there merges + # with the unit's own file in /etc. + unitGenerator = pkgs.writeShellScript "hyperhive-memory-cap" '' + ${readCap} + dir="$1/hive-subagent-daemon.service.d" + ${pkgs.coreutils}/bin/mkdir -p "$dir" + printf '[Service]\nMemoryHigh=%s\n' "$((bytes * 2 / 3))" > "$dir/50-memory-high.conf" + ''; + + # Sourced by every shell's `/etc/set-environment`, so POSIX sh only. + profile = pkgs.writeText "hyperhive-memory-cap.sh" '' + __hyperhive_cap=$(${envGenerator}) + case "$__hyperhive_cap" in + BUN_JSC_forceRAMSize=?*) export "$__hyperhive_cap" ;; + esac + unset __hyperhive_cap + ''; +in +{ + environment.etc."systemd/system-environment-generators/50-hyperhive-memory-cap".source = + envGenerator; + systemd.generators.hyperhive-memory-cap = unitGenerator; + environment.etc."hyperhive/memory-cap.sh".source = profile; + environment.extraInit = ". /etc/hyperhive/memory-cap.sh"; +} diff --git a/nix/agent-modules/renamed-options.nix b/nix/agent-modules/renamed-options.nix index f0b00b74..82adcc27 100644 --- a/nix/agent-modules/renamed-options.nix +++ b/nix/agent-modules/renamed-options.nix @@ -45,10 +45,6 @@ [ "hyperhive" "claudeMarketplaces" ] [ "services" "hyperhive" "agent" "claudeMarketplaces" ] ) - (lib.mkRenamedOptionModule - [ "hyperhive" "claudeMemoryMaxBytes" ] - [ "services" "hyperhive" "agent" "claudeMemoryMaxBytes" ] - ) (lib.mkRenamedOptionModule [ "hyperhive" "claudePlugins" ] [ "services" "hyperhive" "agent" "claudePlugins" ] diff --git a/nix/checks.nix b/nix/checks.nix index 04c3d8bc..8c47b8ca 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -138,6 +138,12 @@ in inherit pkgs self nixosSystem; inherit (pkgs) lib; }; + # Runs ./agent-modules/memory-cap.nix's generators and login-shell snippet + # against sample cap files. + script-test-agent-memory-cap = import ./script-tests/agent-memory-cap.nix { + inherit pkgs self nixosSystem; + inherit (pkgs) lib; + }; module-eval-agent-github-bao = import ./module-eval/agent-github-bao.nix { inherit pkgs self nixosSystem; inherit (pkgs) lib; diff --git a/nix/module-eval/agent-memory.nix b/nix/module-eval/agent-memory.nix index dd1b09dc..fd81a954 100644 --- a/nix/module-eval/agent-memory.nix +++ b/nix/module-eval/agent-memory.nix @@ -22,39 +22,17 @@ let agentHarness ; - # The memory-pressure pair. `claudeMemoryMaxBytes` is the container's own - # cap, rendered per agent by meta.rs — the capped arm is the one every - # real deploy gets, the uncapped arm is a hive that set `infinity` or a - # RAM percentage and so hands the module no byte count to size against. - agentCapped = agent { claudeMemoryMaxBytes = 8589934592; }; - - agentUncapped = agent { }; + agentDefault = agent { }; agentBashDaemon = machine: machine.systemd.services.hive-bash-daemon; agentSubagentDaemon = machine: machine.systemd.services.hive-subagent-daemon; cases = [ { - # Two thirds of the container's cap, and a SOFT ceiling: the daemon's - # cgroup holds every nested claude, so `MemoryHigh=` throttles the - # subagent set as a whole before the kernel picks a victim, while the - # absent `MemoryMax=` is what still lets one subagent use more than - # its share on a container that has the memory free. A hard cap here - # would trade the silent kill for a guaranteed wall, which is the - # shape this deliberately does not have. - name = "the subagent daemon throttles at two thirds of the container's memory"; - ok = - let - c = (agentSubagentDaemon agentCapped).serviceConfig; - in - c.MemoryHigh == "5726623061" && !(c ? MemoryMax); - } - { - # What makes the case above able to fail. With no byte count for the - # container there is no fraction to take, and a hardcoded fallback - # would be a number about some other hive's machine — so the unit - # renders no ceiling at all rather than a fabricated one. - name = "an agent with no byte-valued memory cap renders no subagent ceiling"; - ok = !((agentSubagentDaemon agentUncapped).serviceConfig ? MemoryHigh); + # The ceiling is sized at boot from the container's cap + # (../agent-modules/memory-cap.nix). One in the unit would be the cap at + # the last rebuild, and every agent would carry its own hive's number. + name = "the subagent daemon's memory ceiling is not built into its unit"; + ok = !((agentSubagentDaemon agentDefault).serviceConfig ? MemoryHigh); } { # The sign is the whole property, and it is easy to write backwards: @@ -68,8 +46,8 @@ let name = "the OOM killer prefers a subagent over the agent's own session"; ok = let - sub = (agentSubagentDaemon agentUncapped).serviceConfig.OOMScoreAdjust; - own = (agentHarness agentUncapped).serviceConfig.OOMScoreAdjust; + sub = (agentSubagentDaemon agentDefault).serviceConfig.OOMScoreAdjust; + own = (agentHarness agentDefault).serviceConfig.OOMScoreAdjust; in sub > 0 && own < 0 && sub > own; } @@ -81,13 +59,13 @@ let # chose, which is the blast radius that made the preference a bad # trade in the first place. name = "one subagent losing the OOM draw does not stop the daemon"; - ok = (agentSubagentDaemon agentUncapped).serviceConfig.OOMPolicy == "continue"; + ok = (agentSubagentDaemon agentDefault).serviceConfig.OOMPolicy == "continue"; } { # Each bash task is a process in the daemon's cgroup; under `stop` one # OOM-killed task would end every running sibling task. name = "one bash task losing the OOM draw does not stop the daemon"; - ok = (agentBashDaemon agentUncapped).serviceConfig.OOMPolicy or null == "continue"; + ok = (agentBashDaemon agentDefault).serviceConfig.OOMPolicy or null == "continue"; } ]; in diff --git a/nix/script-tests/agent-memory-cap.nix b/nix/script-tests/agent-memory-cap.nix new file mode 100644 index 00000000..f347176a --- /dev/null +++ b/nix/script-tests/agent-memory-cap.nix @@ -0,0 +1,42 @@ +# `checks.script-test-agent-memory-cap` — runs the three readers of +# `/run/hive/memory-max-bytes` in ../agent-modules/memory-cap.nix: the +# environment generator, the unit generator, and the login-shell snippet +# `/etc/set-environment` sources. The cases are in ./agent-memory-cap.sh. +# +# What runs is the rendered scripts. The one edit is the cap file's path, moved +# under the build directory, and the snippet's reference to the environment +# generator, pointed at that edited copy. +{ + pkgs, + lib, + self, + nixosSystem, +}: +let + inherit + (import ../module-eval/lib.nix { + inherit + pkgs + lib + self + nixosSystem + ; + }) + agentWith + ; + + machine = agentWith { }; +in +pkgs.runCommand "hyperhive-script-test-agent-memory-cap" + { + ENV_GENERATOR = + machine.environment.etc."systemd/system-environment-generators/50-hyperhive-memory-cap".source; + UNIT_GENERATOR = machine.systemd.generators.hyperhive-memory-cap; + PROFILE = machine.environment.etc."hyperhive/memory-cap.sh".source; + EXTRA_INIT = machine.environment.extraInit; + SHELLS = "${pkgs.dash}/bin/dash ${pkgs.bash}/bin/sh"; + } + '' + ${pkgs.bash}/bin/bash ${./agent-memory-cap.sh} + touch "$out" + '' diff --git a/nix/script-tests/agent-memory-cap.sh b/nix/script-tests/agent-memory-cap.sh new file mode 100644 index 00000000..22b9e1f0 --- /dev/null +++ b/nix/script-tests/agent-memory-cap.sh @@ -0,0 +1,94 @@ +# Cases for ./agent-memory-cap.nix. Each case writes a cap file (or none), runs +# both generators with a clean environment and compares what they produced, +# then sources the login-shell snippet under each POSIX shell and compares what +# a child process sees. +set -uo pipefail + +failures=0 +count=0 + +fail() { + echo "FAILED: $case: $*" >&2 + failures=$((failures + 1)) +} + +dir=$(mktemp -d) +cap=$dir/memory-max-bytes +sed "s|/run/hive/memory-max-bytes|$cap|g" "$ENV_GENERATOR" >"$dir/env-generator" +sed "s|/run/hive/memory-max-bytes|$cap|g" "$UNIT_GENERATOR" >"$dir/unit-generator" +chmod +x "$dir/env-generator" "$dir/unit-generator" +sed "s|$ENV_GENERATOR|$dir/env-generator|g" "$PROFILE" >"$dir/profile" +envbin=$(command -v env) + +case="setup" +for g in env-generator unit-generator; do + if grep -q '/run/hive/memory-max-bytes' "$dir/$g"; then fail "$g still names /run/hive after the rewrite"; fi + if ! grep -qF "$cap" "$dir/$g"; then fail "the rewrite did not reach $g's read"; fi +done +if ! grep -qF "$dir/env-generator" "$dir/profile"; then fail "the snippet does not call the rewritten generator"; fi +if ! printf '%s\n' "$EXTRA_INIT" | grep -qxF '. /etc/hyperhive/memory-cap.sh'; then + fail "/etc/set-environment does not source the snippet" +fi + +# check +check() { + case=$1 + count=$((count + 1)) + local want_env=${2:+BUN_JSC_forceRAMSize=$2} want_high=$3 got + got=$(env -i "$dir/env-generator") + if [ "$got" != "$want_env" ]; then + fail "environment generator printed:"$'\n'"$got"$'\n'"wanted:"$'\n'"$want_env" + fi + + local out=$dir/generator-out + rm -rf "$out" + mkdir -p "$out/normal" "$out/early" "$out/late" + env -i "$dir/unit-generator" "$out/normal" "$out/early" "$out/late" + local dropin=$out/normal/hive-subagent-daemon.service.d/50-memory-high.conf + if [ -z "$want_high" ]; then + got=$(find "$out" -type f) + if [ -n "$got" ]; then fail "unit generator wrote:"$'\n'"$got"; fi + else + got=$(cat "$dropin" 2>&1) + if [ "$got" != "[Service]"$'\n'"MemoryHigh=$want_high" ]; then + fail "unit generator's drop-in:"$'\n'"$got" + fi + fi + + local sh + for sh in $SHELLS; do + # Only what the snippet exported reaches the child's environment. No PATH: + # the snippet must not need one. + got=$(env -i "$sh" -c ". '$dir/profile'; exec '$envbin'" | grep -E '^(BUN_|LD_PRELOAD=|__hyperhive)') + if [ "$got" != "$want_env" ]; then + fail "$sh: a child of the sourcing shell saw:"$'\n'"$got" + fi + done +} + +printf '8589934592\n' >"$cap" +check "an 8G cap" 6442450944 5726623061 + +printf '4294967296' >"$cap" +check "a line without a newline is kept" 3221225472 2863311530 + +printf '4294967296\nLD_PRELOAD=/x\n' >"$cap" +check "only the first line is read" 3221225472 2863311530 + +printf '8G\n' >"$cap" +check "a size with a suffix is ignored" "" "" + +printf '0123\n' >"$cap" +check "a leading zero is ignored" "" "" + +printf '1234567890123456\n' >"$cap" +check "sixteen digits are ignored" "" "" + +: >"$cap" +check "an empty file" "" "" + +rm -f "$cap" +check "no file" "" "" + +echo "$count cases, $failures failed" +[ "$failures" -eq 0 ] From 57955a6c53aaa0470b7c203aead0dd61cbe813fa Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 12 Oct 2026 10:10:11 +0200 Subject: [PATCH 2/4] agents: hand over the kept drop-in's cap, warn at boot without one When hive-c0re can't read resource-limits.json and a limits drop-in already exists, it keeps the drop-in, but it wrote no memory cap file. A container whose runtime dir had no file yet (hive-c0re upgraded, /run not cleared) then started with no heap ceiling and no subagent MemoryHigh=, silently. `stage_limits` now writes the file on every call: from the new limits, or from the last `MemoryMax=` in the kept drop-in (`infinity` when it has none or can't be read). A missing or malformed cap file at boot is no longer silent. The generators run before the container's journal and an nspawn container has no /dev/kmsg, so a oneshot `hyperhive-memory-cap-check.service` logs a warning naming the path. The agent then runs with no JSC heap ceiling and no subagent MemoryHigh=; the container's MemoryMax= still applies. module-eval-agent-memory gains a case pinning how the ceiling reaches claude: `hive-agent` and `hive-subagent-daemon` neither set nor unset BUN_JSC_forceRAMSize, both generators and the check unit are installed. Part of #5081 (slice S0-2). --- docs/scheduler/coordinator.md | 5 ++ hive-c0re/src/lifecycle/host_config.rs | 63 ++++++++++++++++++++++---- nix/agent-modules/memory-cap.nix | 45 ++++++++++++++---- nix/module-eval/agent-memory.nix | 20 ++++++++ nix/script-tests/agent-memory-cap.nix | 8 ++-- nix/script-tests/agent-memory-cap.sh | 35 +++++++++----- 6 files changed, 144 insertions(+), 32 deletions(-) diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index 3b87bcec..85b9e006 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -459,6 +459,11 @@ values from it: `BUN_JSC_forceRAMSize` (75%, claude-code's heap ceiling) and `MemoryHigh=` on `hive-subagent-daemon` (two thirds). Neither is in the agent's build, so a changed cap reaches them at the next container start without a rebuild. A cap of `infinity` or a RAM percentage sets neither. +When hive-c0re keeps the last drop-in because it can't read the overrides +file, the file carries that drop-in's `MemoryMax=`. A missing or malformed +file sets neither value, and `hyperhive-memory-cap-check.service` logs a +warning naming the path in the container's journal at boot. The +container's `MemoryMax=` still applies. The same drop-in carries `CPUWeight=` / `IOWeight=` from `agentCpuWeight` / `agentIoWeight`. Those are a different kind of diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index afb00da5..a5e763eb 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -61,8 +61,8 @@ const MEMORY_CAP_FILE: &str = "memory-max-bytes"; /// [`limits_to_apply`], plus handing the memory cap to the agent through /// [`MEMORY_CAP_FILE`] in `runtime_dir`. The container cannot read the cap /// systemd enforces on it, so this file is how it learns the value it sizes -/// its heap and its subagents against. When the drop-in is kept as it is, so is -/// the file. +/// its heap and its subagents against. It is written on every call, from the +/// `MemoryMax=` the agent will start under: the new one, or the kept drop-in's. fn stage_limits( agent_name: &str, limits_path: &Path, @@ -71,12 +71,39 @@ fn stage_limits( hive: &HiveEnv, ) -> Result> { let limits = limits_to_apply(agent_name, limits_path, dropin_path, hive); - if let Some((_, memory_max)) = &limits { - write_memory_cap(runtime_dir, agent_name, memory_max)?; - } + let memory_max = match &limits { + Some((_, memory_max)) => memory_max.clone(), + None => dropin_memory_max(agent_name, dropin_path), + }; + write_memory_cap(runtime_dir, agent_name, &memory_max)?; Ok(limits) } +/// The `MemoryMax=` the drop-in at `path` applies: its last assignment, as +/// systemd reads it. `infinity`, systemd's default, when it has none, its last +/// one is empty, or the file can't be read. +fn dropin_memory_max(agent_name: &str, path: &Path) -> String { + let body = match std::fs::read_to_string(path) { + Ok(body) => body, + Err(e) => { + tracing::warn!( + agent = %agent_name, + path = %path.display(), + error = ?e, + "limits drop-in unreadable; the agent gets no memory cap byte count" + ); + return "infinity".to_owned(); + } + }; + body.lines() + .rev() + .find_map(|line| line.trim().strip_prefix("MemoryMax=")) + .map(str::trim) + .filter(|v| !v.is_empty()) + .unwrap_or("infinity") + .to_owned() +} + /// Write `memory_max` into [`MEMORY_CAP_FILE`] as a byte count, or remove the /// file when the value has none: `infinity`, or a RAM percentage, which needs /// the host's `MemTotal` to resolve. With no file the agent sets no heap or @@ -815,15 +842,33 @@ mod tests { } #[test] - fn a_kept_dropin_keeps_the_agents_copy() { + fn a_kept_dropin_hands_the_agent_its_cap() { let dir = tempfile::tempdir().expect("tempdir"); let limits = dir.path().join("resource-limits.json"); + let dropin = dir.path().join("hyperhive-limits.conf"); + let copy = dir.path().join("runtime").join(MEMORY_CAP_FILE); std::fs::write(&limits, r#"{"sock": {"memory_max": "1G"}}"#).expect("seed limits"); assert_eq!(stage(&dir).1, Some("1073741824\n".to_owned())); + // Unreadable limits keep the drop-in; the copy follows the drop-in. std::fs::write(&limits, TRUNCATED_LIMITS).expect("seed limits"); - std::fs::write(dir.path().join("hyperhive-limits.conf"), "MemoryMax=1G\n") - .expect("seed drop-in"); - assert_eq!(stage(&dir), (None, Some("1073741824\n".to_owned()))); + std::fs::write( + &dropin, + "[Unit]\nConditionPathIsDirectory=/rt/sock\n\n[Service]\nRestartSec=5\nMemoryMax=2G\nCPUQuota=200%\n", + ) + .expect("seed drop-in"); + assert_eq!(stage(&dir), (None, Some("2147483648\n".to_owned()))); + + // A kept drop-in with no copy beside it, as after an upgrade. + std::fs::remove_file(©).expect("remove copy"); + assert_eq!(stage(&dir), (None, Some("2147483648\n".to_owned()))); + + // The last assignment is the one systemd applies. + std::fs::write(&dropin, "[Service]\nMemoryMax=2G\nMemoryMax=3G\n").expect("seed drop-in"); + assert_eq!(stage(&dir), (None, Some("3221225472\n".to_owned()))); + + // No `MemoryMax=` is systemd's default, `infinity`: no byte count. + std::fs::write(&dropin, "[Service]\nCPUQuota=200%\n").expect("seed drop-in"); + assert_eq!(stage(&dir), (None, None)); } } diff --git a/nix/agent-modules/memory-cap.nix b/nix/agent-modules/memory-cap.nix index 47c29968..477bd023 100644 --- a/nix/agent-modules/memory-cap.nix +++ b/nix/agent-modules/memory-cap.nix @@ -6,7 +6,8 @@ # inside. hive-c0re writes its byte count to `/run/hive/memory-max-bytes` # before every start (`lifecycle::host_config`), and removes the file when the # cap is `infinity` or a RAM percentage. Two values are derived from it, and -# neither is set without the file: +# neither is set without the file; `hyperhive-memory-cap-check` then logs a +# warning at boot: # # - `BUN_JSC_forceRAMSize`, 75% of the cap. Bun/JavaScriptCore, the runtime # under claude-code, sizes its heap ceiling off `/proc/meminfo`, which under @@ -15,6 +16,8 @@ # generator hands it to every unit of the container's systemd, `machinectl # shell` sessions (`hivectl choom`) included; `/etc/set-environment` exports # it in login shells that no unit starts (`nixos-container root-login`). +# `hive-agent` and `hive-subagent-daemon` spawn `claude` as a plain child, +# which inherits it, so neither unit may set or unset it. # - `MemoryHigh=` on `hive-subagent-daemon`, two thirds of the cap, as a # generator drop-in. The daemon spawns nested `claude` sessions as plain # children, so its cgroup already is the "all subagents" cgroup. `High`, not @@ -22,34 +25,50 @@ # last third stays free for the agent's own turn, but a subagent that needs # more still gets it when the container has memory to spare. # -# Tested by `checks.script-test-agent-memory-cap`. +# Tested by `checks.script-test-agent-memory-cap` and +# `checks.module-eval-agent-memory`. { pkgs, ... }: let - # Sets `bytes`, or exits when there is no file or its first line is not a - # plain byte count. A leading zero would read as octal, and 16 digits or - # more could overflow `bytes * 75`. Shell builtins only. - readCap = '' + # Sets `bytes`, or runs `noCap` when there is no file or its first line is + # not a plain byte count. A leading zero would read as octal, and 16 digits + # or more could overflow `bytes * 75`. Shell builtins only. + readCap = noCap: '' bytes= [ -r /run/hive/memory-max-bytes ] && read -r bytes < /run/hive/memory-max-bytes case "$bytes" in - "" | 0* | *[!0-9]* | ????????????????*) exit 0 ;; + "" | 0* | *[!0-9]* | ????????????????*) ${noCap} ;; esac ''; envGenerator = pkgs.writeShellScript "hyperhive-memory-cap-env" '' - ${readCap} + ${readCap "exit 0"} printf 'BUN_JSC_forceRAMSize=%s\n' "$((bytes * 75 / 100))" ''; # `$1` is the generator's normal-priority output dir. A drop-in there merges # with the unit's own file in /etc. unitGenerator = pkgs.writeShellScript "hyperhive-memory-cap" '' - ${readCap} + ${readCap "exit 0"} dir="$1/hive-subagent-daemon.service.d" ${pkgs.coreutils}/bin/mkdir -p "$dir" printf '[Service]\nMemoryHigh=%s\n' "$((bytes * 2 / 3))" > "$dir/50-memory-high.conf" ''; + # The generators run before the container's journal starts and an nspawn + # container has no /dev/kmsg, so the boot warning for an unusable cap comes + # from a unit. `<4>` is the journal's warning priority. + check = pkgs.writeShellScript "hyperhive-memory-cap-check" '' + ${readCap '' + if [ -e /run/hive/memory-max-bytes ]; then + why="does not start with a plain byte count" + else + why="is missing" + fi + echo "<4>/run/hive/memory-max-bytes $why: no BUN_JSC_forceRAMSize heap ceiling and no MemoryHigh= on hive-subagent-daemon; the host's MemoryMax= on the container still applies. hive-c0re writes no file for a cap of infinity or a RAM percentage." >&2 + exit 0 + ''} + ''; + # Sourced by every shell's `/etc/set-environment`, so POSIX sh only. profile = pkgs.writeText "hyperhive-memory-cap.sh" '' __hyperhive_cap=$(${envGenerator}) @@ -63,6 +82,14 @@ in environment.etc."systemd/system-environment-generators/50-hyperhive-memory-cap".source = envGenerator; systemd.generators.hyperhive-memory-cap = unitGenerator; + systemd.services.hyperhive-memory-cap-check = { + description = "Warn when the container's memory cap is unusable"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = check; + }; + }; environment.etc."hyperhive/memory-cap.sh".source = profile; environment.extraInit = ". /etc/hyperhive/memory-cap.sh"; } diff --git a/nix/module-eval/agent-memory.nix b/nix/module-eval/agent-memory.nix index fd81a954..639309c5 100644 --- a/nix/module-eval/agent-memory.nix +++ b/nix/module-eval/agent-memory.nix @@ -34,6 +34,26 @@ let name = "the subagent daemon's memory ceiling is not built into its unit"; ok = !((agentSubagentDaemon agentDefault).serviceConfig ? MemoryHigh); } + { + # claude's heap ceiling reaches it only as inherited environment: the + # environment generator fills the manager's, systemd hands that to every + # unit, and both units spawn claude as a plain child. A unit that sets + # or unsets the variable itself cuts that. Without the two generators the + # cap reaches nothing, and without the check unit nothing says so. + name = "claude's units take the heap ceiling from the memory cap read at boot"; + ok = + let + leavesIt = + unit: + !(unit.environment ? BUN_JSC_forceRAMSize) + && !(lib.elem "BUN_JSC_forceRAMSize" (lib.toList (unit.serviceConfig.UnsetEnvironment or [ ]))); + in + leavesIt (agentHarness agentDefault) + && leavesIt (agentSubagentDaemon agentDefault) + && agentDefault.environment.etc ? "systemd/system-environment-generators/50-hyperhive-memory-cap" + && agentDefault.systemd.generators ? hyperhive-memory-cap + && lib.elem "multi-user.target" agentDefault.systemd.services.hyperhive-memory-cap-check.wantedBy; + } { # The sign is the whole property, and it is easy to write backwards: # a HIGHER OOMScoreAdjust is a MORE likely victim. So the subagent diff --git a/nix/script-tests/agent-memory-cap.nix b/nix/script-tests/agent-memory-cap.nix index f347176a..7844673e 100644 --- a/nix/script-tests/agent-memory-cap.nix +++ b/nix/script-tests/agent-memory-cap.nix @@ -1,7 +1,8 @@ -# `checks.script-test-agent-memory-cap` — runs the three readers of +# `checks.script-test-agent-memory-cap` — runs the four readers of # `/run/hive/memory-max-bytes` in ../agent-modules/memory-cap.nix: the -# environment generator, the unit generator, and the login-shell snippet -# `/etc/set-environment` sources. The cases are in ./agent-memory-cap.sh. +# environment generator, the unit generator, the boot check that warns about +# an unusable cap, and the login-shell snippet `/etc/set-environment` sources. +# The cases are in ./agent-memory-cap.sh. # # What runs is the rendered scripts. The one edit is the cap file's path, moved # under the build directory, and the snippet's reference to the environment @@ -32,6 +33,7 @@ pkgs.runCommand "hyperhive-script-test-agent-memory-cap" ENV_GENERATOR = machine.environment.etc."systemd/system-environment-generators/50-hyperhive-memory-cap".source; UNIT_GENERATOR = machine.systemd.generators.hyperhive-memory-cap; + CHECK = machine.systemd.services.hyperhive-memory-cap-check.serviceConfig.ExecStart; PROFILE = machine.environment.etc."hyperhive/memory-cap.sh".source; EXTRA_INIT = machine.environment.extraInit; SHELLS = "${pkgs.dash}/bin/dash ${pkgs.bash}/bin/sh"; diff --git a/nix/script-tests/agent-memory-cap.sh b/nix/script-tests/agent-memory-cap.sh index 22b9e1f0..fc873486 100644 --- a/nix/script-tests/agent-memory-cap.sh +++ b/nix/script-tests/agent-memory-cap.sh @@ -1,7 +1,7 @@ # Cases for ./agent-memory-cap.nix. Each case writes a cap file (or none), runs -# both generators with a clean environment and compares what they produced, -# then sources the login-shell snippet under each POSIX shell and compares what -# a child process sees. +# both generators and the boot check with a clean environment and compares what +# they produced, then sources the login-shell snippet under each POSIX shell +# and compares what a child process sees. set -uo pipefail failures=0 @@ -16,12 +16,13 @@ dir=$(mktemp -d) cap=$dir/memory-max-bytes sed "s|/run/hive/memory-max-bytes|$cap|g" "$ENV_GENERATOR" >"$dir/env-generator" sed "s|/run/hive/memory-max-bytes|$cap|g" "$UNIT_GENERATOR" >"$dir/unit-generator" -chmod +x "$dir/env-generator" "$dir/unit-generator" +sed "s|/run/hive/memory-max-bytes|$cap|g" "$CHECK" >"$dir/check" +chmod +x "$dir/env-generator" "$dir/unit-generator" "$dir/check" sed "s|$ENV_GENERATOR|$dir/env-generator|g" "$PROFILE" >"$dir/profile" envbin=$(command -v env) case="setup" -for g in env-generator unit-generator; do +for g in env-generator unit-generator check; do if grep -q '/run/hive/memory-max-bytes' "$dir/$g"; then fail "$g still names /run/hive after the rewrite"; fi if ! grep -qF "$cap" "$dir/$g"; then fail "the rewrite did not reach $g's read"; fi done @@ -31,10 +32,11 @@ if ! printf '%s\n' "$EXTRA_INIT" | grep -qxF '. /etc/hyperhive/memory-cap.sh'; t fi # check +# check() { case=$1 count=$((count + 1)) - local want_env=${2:+BUN_JSC_forceRAMSize=$2} want_high=$3 got + local want_env=${2:+BUN_JSC_forceRAMSize=$2} want_high=$3 want_why=${4:-} got got=$(env -i "$dir/env-generator") if [ "$got" != "$want_env" ]; then fail "environment generator printed:"$'\n'"$got"$'\n'"wanted:"$'\n'"$want_env" @@ -55,6 +57,17 @@ check() { fi fi + # The warning goes to stderr with the journal's `<4>` priority prefix, and + # nothing goes anywhere for a usable cap. + got=$(env -i "$dir/check" 2>&1) + local want_warn= + if [ -n "$want_why" ]; then + want_warn="<4>$cap $want_why: no BUN_JSC_forceRAMSize heap ceiling and no MemoryHigh= on hive-subagent-daemon; the host's MemoryMax= on the container still applies. hive-c0re writes no file for a cap of infinity or a RAM percentage." + fi + if [ "$got" != "$want_warn" ]; then + fail "boot check printed:"$'\n'"$got"$'\n'"wanted:"$'\n'"$want_warn" + fi + local sh for sh in $SHELLS; do # Only what the snippet exported reaches the child's environment. No PATH: @@ -76,19 +89,19 @@ printf '4294967296\nLD_PRELOAD=/x\n' >"$cap" check "only the first line is read" 3221225472 2863311530 printf '8G\n' >"$cap" -check "a size with a suffix is ignored" "" "" +check "a size with a suffix is ignored" "" "" "does not start with a plain byte count" printf '0123\n' >"$cap" -check "a leading zero is ignored" "" "" +check "a leading zero is ignored" "" "" "does not start with a plain byte count" printf '1234567890123456\n' >"$cap" -check "sixteen digits are ignored" "" "" +check "sixteen digits are ignored" "" "" "does not start with a plain byte count" : >"$cap" -check "an empty file" "" "" +check "an empty file" "" "" "does not start with a plain byte count" rm -f "$cap" -check "no file" "" "" +check "no file" "" "" "is missing" echo "$count cases, $failures failed" [ "$failures" -eq 0 ] From eaca3c4eb36977ac6a15cc54dfe07a7c116a204b Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 12 Oct 2026 10:35:15 +0200 Subject: [PATCH 3/4] agents: always write the memory cap, `infinity` for none The cap file was removed for a cap of `infinity` or a RAM percentage, so the boot check warned on every start of such an agent, for a legitimate config. The file is now written on every start: - a size: its byte count, as before; - `infinity`: the literal `infinity`. The generators set no JSC heap ceiling and no subagent MemoryHigh=, and the boot check is quiet; - a percentage: resolved on the host the way systemd resolves MemoryMax=N% for the container@ unit. That is permyriad (at most two decimals, at most 100%) of sysconf(_SC_PHYS_PAGES) * sysconf(_SC_PAGESIZE), rounded down to a page; - a value systemd ignores (zero, over 100%, more than two decimals, unparseable) leaves the unit uncapped, so it is `infinity`. The file is missing only when hive-c0re can't tell the cap: the limits file and the kept drop-in are both unreadable, or a percentage meets an unknown host memory size. The boot check now warns only for a missing file or a first line that is neither a byte count nor `infinity`. Part of #5081 (slice S0-2). --- docs/agent-lifecycle/persistence.md | 7 +- docs/scheduler/coordinator.md | 26 +-- hive-c0re/src/lifecycle/host_config.rs | 241 +++++++++++++++++++++---- nix/agent-modules/memory-cap.nix | 25 +-- nix/script-tests/agent-memory-cap.sh | 21 ++- 5 files changed, 253 insertions(+), 67 deletions(-) diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index c12d7aab..3ca1043c 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -404,7 +404,7 @@ Contents: `lifecycle::write_dropins` generates the systemd drop-in, **not** injected into the container — these are host-side caps on the container, so the capped party never sets them. It sees only the resulting memory cap, - as a byte count in its runtime dir (below). Fallback is per *field*: + as a byte count or `infinity` in its runtime dir (below). Fallback is per *field*: an absent file, absent agent, or absent field falls back to the hive-wide `services.hyperhive.c0re.agentCpuQuota` / `agentMemoryMax`, so an agent can override only its memory and still track the hive @@ -510,8 +510,9 @@ containers can't start. - `/run/hyperhive/agents//mcp.sock` — per-agent socket (bind-mounted into the container as `/run/hive/mcp.sock`). - `/run/hyperhive/agents//memory-max-bytes` — the agent's effective - `MemoryMax=` in bytes, written by `lifecycle::write_dropins` before every - start (absent when the cap is `infinity` or a RAM percentage). The + `MemoryMax=` as a byte count (a percentage resolved against the host's + physical memory) or `infinity`, written by `lifecycle::write_dropins` + before every start. Absent only when hive-c0re can't tell the cap. The container reads it at boot as `/run/hive/memory-max-bytes` (`nix/agent-modules/memory-cap.nix`). diff --git a/docs/scheduler/coordinator.md b/docs/scheduler/coordinator.md index 85b9e006..20d4dab3 100644 --- a/docs/scheduler/coordinator.md +++ b/docs/scheduler/coordinator.md @@ -453,17 +453,21 @@ rebuild, so changes take effect on the next lifecycle op without requiring a host rebuild. The container can't read its own cap, so `write_dropins` also writes the -effective `MemoryMax=` in bytes into the agent's runtime dir -(`/run/hive/memory-max-bytes` inside). At boot the container derives two -values from it: `BUN_JSC_forceRAMSize` (75%, claude-code's heap ceiling) -and `MemoryHigh=` on `hive-subagent-daemon` (two thirds). Neither is in the -agent's build, so a changed cap reaches them at the next container start -without a rebuild. A cap of `infinity` or a RAM percentage sets neither. -When hive-c0re keeps the last drop-in because it can't read the overrides -file, the file carries that drop-in's `MemoryMax=`. A missing or malformed -file sets neither value, and `hyperhive-memory-cap-check.service` logs a -warning naming the path in the container's journal at boot. The -container's `MemoryMax=` still applies. +effective `MemoryMax=` into the agent's runtime dir +(`/run/hive/memory-max-bytes` inside) before every start: a byte count, or +`infinity` for no cap. A percentage becomes bytes the way systemd resolves +it, as that share of the host's physical memory rounded down to a page. A +value systemd ignores (zero, over 100%) is `infinity`. When hive-c0re keeps +the last drop-in because it can't read the overrides file, the file carries +that drop-in's `MemoryMax=`. + +At boot the container derives two values from a byte count: +`BUN_JSC_forceRAMSize` (75%, claude-code's heap ceiling) and `MemoryHigh=` on +`hive-subagent-daemon` (two thirds). Neither is in the agent's build, so a +changed cap reaches them at the next container start without a rebuild. +`infinity` sets neither. A missing or malformed file sets neither as well, and +`hyperhive-memory-cap-check.service` logs a warning naming the path in the +container's journal. The container's `MemoryMax=` still applies. The same drop-in carries `CPUWeight=` / `IOWeight=` from `agentCpuWeight` / `agentIoWeight`. Those are a different kind of diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index a5e763eb..0552b1fd 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -31,6 +31,7 @@ pub async fn write_dropins(name: &str, hive: &HiveEnv, paths: &AgentPaths) -> Re &limits_dropin_path(&container), &paths.agent, hive, + HostMemory::read(), )? { set_resource_limits( &container, @@ -54,10 +55,40 @@ fn limits_dropin_path(container: &str) -> PathBuf { } /// File in the agent's runtime dir, `/run/hive/` in the container, holding the -/// byte count of the `MemoryMax=` its drop-in carries. -/// `nix/agent-modules/memory-cap.nix` reads it at boot under this name. +/// `MemoryMax=` its drop-in carries: a byte count, or `infinity` for no cap. +/// `nix/agent-modules/memory-cap.nix` reads it at boot under this name, and +/// treats a missing file as a fault. const MEMORY_CAP_FILE: &str = "memory-max-bytes"; +/// The host's physical memory and page size, the two numbers systemd resolves +/// a `MemoryMax=` percentage against. +#[derive(Clone, Copy, Debug)] +struct HostMemory { + bytes: u64, + page: u64, +} + +impl HostMemory { + /// `sysconf(_SC_PHYS_PAGES) * sysconf(_SC_PAGESIZE)`, as systemd's + /// `physical_memory()` computes it. systemd also caps that by the root + /// cgroup's `memory.max`, which the host's root cgroup does not have. + fn read() -> Option { + // SAFETY: `sysconf(3)` only reads system configuration. + let (pages, page) = unsafe { + ( + libc::sysconf(libc::_SC_PHYS_PAGES), + libc::sysconf(libc::_SC_PAGESIZE), + ) + }; + let pages = u64::try_from(pages).ok().filter(|&p| p > 0)?; + let page = u64::try_from(page).ok().filter(|&p| p > 0)?; + Some(Self { + bytes: pages.checked_mul(page)?, + page, + }) + } +} + /// [`limits_to_apply`], plus handing the memory cap to the agent through /// [`MEMORY_CAP_FILE`] in `runtime_dir`. The container cannot read the cap /// systemd enforces on it, so this file is how it learns the value it sizes @@ -69,20 +100,22 @@ fn stage_limits( dropin_path: &Path, runtime_dir: &Path, hive: &HiveEnv, + host: Option, ) -> Result> { let limits = limits_to_apply(agent_name, limits_path, dropin_path, hive); let memory_max = match &limits { - Some((_, memory_max)) => memory_max.clone(), + Some((_, memory_max)) => Some(memory_max.clone()), None => dropin_memory_max(agent_name, dropin_path), }; - write_memory_cap(runtime_dir, agent_name, &memory_max)?; + let cap = memory_max.and_then(|m| memory_cap_value(agent_name, &m, host)); + write_memory_cap(runtime_dir, cap.as_deref())?; Ok(limits) } /// The `MemoryMax=` the drop-in at `path` applies: its last assignment, as -/// systemd reads it. `infinity`, systemd's default, when it has none, its last -/// one is empty, or the file can't be read. -fn dropin_memory_max(agent_name: &str, path: &Path) -> String { +/// systemd reads it, or `infinity`, systemd's default, when it has none or its +/// last one is empty. `None` when the file can't be read. +fn dropin_memory_max(agent_name: &str, path: &Path) -> Option { let body = match std::fs::read_to_string(path) { Ok(body) => body, Err(e) => { @@ -90,35 +123,90 @@ fn dropin_memory_max(agent_name: &str, path: &Path) -> String { agent = %agent_name, path = %path.display(), error = ?e, - "limits drop-in unreadable; the agent gets no memory cap byte count" + "limits drop-in unreadable; the agent gets no memory cap" ); - return "infinity".to_owned(); + return None; } }; - body.lines() + let value = body + .lines() .rev() .find_map(|line| line.trim().strip_prefix("MemoryMax=")) .map(str::trim) .filter(|v| !v.is_empty()) - .unwrap_or("infinity") - .to_owned() + .unwrap_or("infinity"); + Some(value.to_owned()) } -/// Write `memory_max` into [`MEMORY_CAP_FILE`] as a byte count, or remove the -/// file when the value has none: `infinity`, or a RAM percentage, which needs -/// the host's `MemTotal` to resolve. With no file the agent sets no heap or -/// subagent ceiling. -fn write_memory_cap(runtime_dir: &Path, agent_name: &str, memory_max: &str) -> Result<()> { - let path = runtime_dir.join(MEMORY_CAP_FILE); - let Some(bytes) = crate::resource_limits::parse_bytes(memory_max) else { - if memory_max.ends_with('%') { +/// What [`MEMORY_CAP_FILE`] says for `memory_max`, read the way systemd reads +/// `MemoryMax=`: a size is its byte count, and a percentage is that share of +/// `host`'s physical memory, rounded down to a page. A value systemd ignores +/// (zero, over 100%, more than two decimals, unparseable) leaves the unit +/// uncapped, so it is `infinity` here. `None` for a percentage when `host` is +/// unknown. +fn memory_cap_value( + agent_name: &str, + memory_max: &str, + host: Option, +) -> Option { + let bytes = if let Some(percent) = memory_max.strip_suffix('%') { + let Some(host) = host else { tracing::warn!( agent = agent_name, memory_max, - "effective MemoryMax= is a RAM percentage; the agent gets no byte count, so \ - no JSC heap ceiling and no subagent MemoryHigh=" + "host physical memory unknown; the agent gets no memory cap" ); + return None; + }; + permyriad(percent).and_then(|p| scale_physical_memory(host, p)) + } else { + crate::resource_limits::parse_bytes(memory_max) + }; + Some( + bytes + .filter(|&b| b > 0) + .map_or_else(|| "infinity".to_owned(), |b| b.to_string()), + ) +} + +/// `percent` (the part before `%`) in hundredths of a percent, with systemd's +/// `parse_permyriad` rules: digits, at most two decimals, at most 100%. +fn permyriad(percent: &str) -> Option { + let (int, frac) = percent.split_once('.').unwrap_or((percent, "")); + if int.is_empty() || !int.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let hundredths = match frac.as_bytes() { + [] if !percent.contains('.') => 0, + [d] if d.is_ascii_digit() => u64::from(d - b'0') * 10, + [d1, d2] if d1.is_ascii_digit() && d2.is_ascii_digit() => { + u64::from(d1 - b'0') * 10 + u64::from(d2 - b'0') } + _ => return None, + }; + let value = int + .parse::() + .ok()? + .checked_mul(100)? + .checked_add(hundredths)?; + (value <= 10_000).then_some(value) +} + +/// systemd's `physical_memory_scale(permyriad, 10000)`: whole pages, rounded +/// down, and the exact total at 100%. +fn scale_physical_memory(host: HostMemory, permyriad: u64) -> Option { + if permyriad == 10_000 { + return Some(host.bytes); + } + let pages = u128::from(host.bytes / host.page) * u128::from(permyriad) / 10_000; + u64::try_from(pages * u128::from(host.page)).ok() +} + +/// Write `cap` into [`MEMORY_CAP_FILE`], or remove the file when the cap is +/// unknown, which the agent reports at boot. +fn write_memory_cap(runtime_dir: &Path, cap: Option<&str>) -> Result<()> { + let path = runtime_dir.join(MEMORY_CAP_FILE); + let Some(cap) = cap else { return match std::fs::remove_file(&path) { Err(e) if e.kind() != std::io::ErrorKind::NotFound => { Err(e).with_context(|| format!("remove {}", path.display())) @@ -129,8 +217,7 @@ fn write_memory_cap(runtime_dir: &Path, agent_name: &str, memory_max: &str) -> R std::fs::create_dir_all(runtime_dir) .with_context(|| format!("create agent runtime dir {}", runtime_dir.display()))?; let tmp = runtime_dir.join(format!(".{MEMORY_CAP_FILE}.tmp")); - std::fs::write(&tmp, format!("{bytes}\n")) - .with_context(|| format!("write {}", tmp.display()))?; + std::fs::write(&tmp, format!("{cap}\n")).with_context(|| format!("write {}", tmp.display()))?; std::fs::rename(&tmp, &path).with_context(|| format!("rename onto {}", path.display())) } @@ -572,9 +659,10 @@ async fn set_nspawn_flags( #[cfg(test)] mod tests { use super::{ - BindMount, MEMORY_CAP_FILE, QUEUE_CLIENT_ID_CREDENTIAL, QUEUE_SECRET_CREDENTIAL, - bind_child_agent_dirs, bind_own_agent_dirs, holds_manage_root_agent, limits_to_apply, - queue_agent_credentials, stage_limits, + BindMount, HostMemory, MEMORY_CAP_FILE, QUEUE_CLIENT_ID_CREDENTIAL, + QUEUE_SECRET_CREDENTIAL, bind_child_agent_dirs, bind_own_agent_dirs, + holds_manage_root_agent, limits_to_apply, memory_cap_value, queue_agent_credentials, + stage_limits, }; use crate::coordinator::HiveEnv; @@ -792,6 +880,12 @@ mod tests { assert_eq!(applied, Some(("200%".to_owned(), "1G".to_owned()))); } + /// 4 000 003 pages of 4 KiB: odd, so rounding to a page shows. + const HOST: HostMemory = HostMemory { + bytes: 4_000_003 * 4096, + page: 4096, + }; + /// Runs [`stage_limits`] against the files in `dir` and returns the /// drop-in's `MemoryMax=` and the agent's copy of it, `None` when absent. fn stage(dir: &tempfile::TempDir) -> (Option, Option) { @@ -802,6 +896,7 @@ mod tests { &dir.path().join("hyperhive-limits.conf"), &runtime, &HiveEnv::default(), + Some(HOST), ) .expect("stage limits"); let copy = std::fs::read_to_string(runtime.join(MEMORY_CAP_FILE)).ok(); @@ -827,18 +922,63 @@ mod tests { } #[test] - fn a_cap_with_no_byte_count_removes_the_agents_copy() { + fn every_kind_of_cap_is_written_for_the_agent() { let dir = tempfile::tempdir().expect("tempdir"); let limits = dir.path().join("resource-limits.json"); - assert_eq!(stage(&dir).1, Some("4294967296\n".to_owned())); + for (memory_max, copy) in [ + ("infinity", "infinity\n"), + ("50%", "8192004096\n"), + ("8G", "8589934592\n"), + ] { + std::fs::write( + &limits, + format!(r#"{{"sock": {{"memory_max": "{memory_max}"}}}}"#), + ) + .expect("seed limits"); + assert_eq!( + stage(&dir), + (Some(memory_max.to_owned()), Some(copy.to_owned())), + "{memory_max}" + ); + } + } - std::fs::write(&limits, r#"{"sock": {"memory_max": "infinity"}}"#).expect("seed limits"); - assert_eq!(stage(&dir), (Some("infinity".to_owned()), None)); + #[test] + fn a_cap_is_read_the_way_systemd_reads_memory_max() { + for (memory_max, cap) in [ + ("4G", "4294967296"), + ("infinity", "infinity"), + ("50%", "8192004096"), + ("33.33%", "5460787200"), + ("12.5%", "2048000000"), + ("100%", "16384012288"), + // systemd ignores these and the unit runs uncapped. + ("0", "infinity"), + ("0%", "infinity"), + ("150%", "infinity"), + ("12.345%", "infinity"), + ("lots", "infinity"), + ] { + assert_eq!( + memory_cap_value("sock", memory_max, Some(HOST)).as_deref(), + Some(cap), + "{memory_max}" + ); + } + assert_eq!(memory_cap_value("sock", "50%", None), None); + assert_eq!( + memory_cap_value("sock", "8G", None).as_deref(), + Some("8589934592") + ); + } - assert_eq!(stage(&dir).1, None, "an absent copy stays absent"); - - std::fs::write(&limits, r#"{"sock": {"memory_max": "50%"}}"#).expect("seed limits"); - assert_eq!(stage(&dir), (Some("50%".to_owned()), None)); + #[test] + fn the_host_memory_systemd_would_read_is_found() { + let host = HostMemory::read().expect("sysconf"); + assert!( + host.page >= 4096 && host.bytes.is_multiple_of(host.page), + "{host:?}" + ); } #[test] @@ -867,8 +1007,37 @@ mod tests { std::fs::write(&dropin, "[Service]\nMemoryMax=2G\nMemoryMax=3G\n").expect("seed drop-in"); assert_eq!(stage(&dir), (None, Some("3221225472\n".to_owned()))); - // No `MemoryMax=` is systemd's default, `infinity`: no byte count. + // No `MemoryMax=` is systemd's default. std::fs::write(&dropin, "[Service]\nCPUQuota=200%\n").expect("seed drop-in"); + assert_eq!(stage(&dir), (None, Some("infinity\n".to_owned()))); + } + + #[test] + fn an_unknown_cap_leaves_no_copy() { + let dir = tempfile::tempdir().expect("tempdir"); + let limits = dir.path().join("resource-limits.json"); + let dropin = dir.path().join("hyperhive-limits.conf"); + assert_eq!(stage(&dir).1, Some("4294967296\n".to_owned())); + + // Unreadable limits and an unreadable drop-in: nothing says what the + // cgroup enforces. + std::fs::write(&limits, TRUNCATED_LIMITS).expect("seed limits"); + std::fs::create_dir(&dropin).expect("seed unreadable drop-in"); assert_eq!(stage(&dir), (None, None)); + + // A percentage with no host memory to resolve it against. + std::fs::write(&limits, r#"{"sock": {"memory_max": "50%"}}"#).expect("seed limits"); + let runtime = dir.path().join("runtime"); + let applied = stage_limits( + "sock", + &limits, + &dropin, + &runtime, + &HiveEnv::default(), + None, + ) + .expect("stage limits"); + assert_eq!(applied.map(|(_, mem)| mem), Some("50%".to_owned())); + assert!(!runtime.join(MEMORY_CAP_FILE).exists()); } } diff --git a/nix/agent-modules/memory-cap.nix b/nix/agent-modules/memory-cap.nix index 477bd023..b570d6a7 100644 --- a/nix/agent-modules/memory-cap.nix +++ b/nix/agent-modules/memory-cap.nix @@ -3,10 +3,11 @@ # # The cap is `MemoryMax=` on the host's `container@` unit, which nothing in # here can read: `/sys/fs/cgroup/memory.max` reads `max` at every level from -# inside. hive-c0re writes its byte count to `/run/hive/memory-max-bytes` -# before every start (`lifecycle::host_config`), and removes the file when the -# cap is `infinity` or a RAM percentage. Two values are derived from it, and -# neither is set without the file; `hyperhive-memory-cap-check` then logs a +# inside. Before every start hive-c0re (`lifecycle::host_config`) writes it to +# `/run/hive/memory-max-bytes` as a byte count, a percentage resolved against +# the host's RAM as systemd resolves it, or `infinity` for no cap. Two values +# are derived from a byte count, and neither is set for `infinity`. A missing +# or malformed file sets neither too, and `hyperhive-memory-cap-check` logs a # warning at boot: # # - `BUN_JSC_forceRAMSize`, 75% of the cap. Bun/JavaScriptCore, the runtime @@ -29,13 +30,15 @@ # `checks.module-eval-agent-memory`. { pkgs, ... }: let - # Sets `bytes`, or runs `noCap` when there is no file or its first line is - # not a plain byte count. A leading zero would read as octal, and 16 digits - # or more could overflow `bytes * 75`. Shell builtins only. + # Sets `bytes`. Exits for `infinity`, and runs `noCap` when there is no file + # or its first line is neither `infinity` nor a plain byte count. A leading + # zero would read as octal, and 16 digits or more could overflow + # `bytes * 75`. Shell builtins only. readCap = noCap: '' bytes= [ -r /run/hive/memory-max-bytes ] && read -r bytes < /run/hive/memory-max-bytes case "$bytes" in + infinity) exit 0 ;; "" | 0* | *[!0-9]* | ????????????????*) ${noCap} ;; esac ''; @@ -55,16 +58,16 @@ let ''; # The generators run before the container's journal starts and an nspawn - # container has no /dev/kmsg, so the boot warning for an unusable cap comes - # from a unit. `<4>` is the journal's warning priority. + # container has no /dev/kmsg, so the boot warning for a missing or malformed + # cap file comes from a unit. `<4>` is the journal's warning priority. check = pkgs.writeShellScript "hyperhive-memory-cap-check" '' ${readCap '' if [ -e /run/hive/memory-max-bytes ]; then - why="does not start with a plain byte count" + why="holds neither a byte count nor infinity" else why="is missing" fi - echo "<4>/run/hive/memory-max-bytes $why: no BUN_JSC_forceRAMSize heap ceiling and no MemoryHigh= on hive-subagent-daemon; the host's MemoryMax= on the container still applies. hive-c0re writes no file for a cap of infinity or a RAM percentage." >&2 + echo "<4>/run/hive/memory-max-bytes $why: no BUN_JSC_forceRAMSize heap ceiling and no MemoryHigh= on hive-subagent-daemon; the host's MemoryMax= on the container still applies." >&2 exit 0 ''} ''; diff --git a/nix/script-tests/agent-memory-cap.sh b/nix/script-tests/agent-memory-cap.sh index fc873486..f60ed37d 100644 --- a/nix/script-tests/agent-memory-cap.sh +++ b/nix/script-tests/agent-memory-cap.sh @@ -58,11 +58,11 @@ check() { fi # The warning goes to stderr with the journal's `<4>` priority prefix, and - # nothing goes anywhere for a usable cap. + # nothing goes anywhere for a byte count or `infinity`. got=$(env -i "$dir/check" 2>&1) local want_warn= if [ -n "$want_why" ]; then - want_warn="<4>$cap $want_why: no BUN_JSC_forceRAMSize heap ceiling and no MemoryHigh= on hive-subagent-daemon; the host's MemoryMax= on the container still applies. hive-c0re writes no file for a cap of infinity or a RAM percentage." + want_warn="<4>$cap $want_why: no BUN_JSC_forceRAMSize heap ceiling and no MemoryHigh= on hive-subagent-daemon; the host's MemoryMax= on the container still applies." fi if [ "$got" != "$want_warn" ]; then fail "boot check printed:"$'\n'"$got"$'\n'"wanted:"$'\n'"$want_warn" @@ -88,17 +88,26 @@ check "a line without a newline is kept" 3221225472 2863311530 printf '4294967296\nLD_PRELOAD=/x\n' >"$cap" check "only the first line is read" 3221225472 2863311530 +printf 'infinity\n' >"$cap" +check "infinity sets nothing and warns of nothing" "" "" "" + +printf 'infinity' >"$cap" +check "infinity without a newline" "" "" "" + +printf 'infinityx\n' >"$cap" +check "a word that only starts with infinity" "" "" "holds neither a byte count nor infinity" + printf '8G\n' >"$cap" -check "a size with a suffix is ignored" "" "" "does not start with a plain byte count" +check "a size with a suffix is ignored" "" "" "holds neither a byte count nor infinity" printf '0123\n' >"$cap" -check "a leading zero is ignored" "" "" "does not start with a plain byte count" +check "a leading zero is ignored" "" "" "holds neither a byte count nor infinity" printf '1234567890123456\n' >"$cap" -check "sixteen digits are ignored" "" "" "does not start with a plain byte count" +check "sixteen digits are ignored" "" "" "holds neither a byte count nor infinity" : >"$cap" -check "an empty file" "" "" "does not start with a plain byte count" +check "an empty file" "" "" "holds neither a byte count nor infinity" rm -f "$cap" check "no file" "" "" "is missing" From fb5eb9edc19ef9fe69d50383ef3a9df6d1a8e681 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 12 Oct 2026 10:57:27 +0200 Subject: [PATCH 4/4] agents: read every MemoryMax= size systemd enforces `parse_bytes` knew only K/M/G/T, in either case. A size with the bare `B` unit (`1048576B`), which `validate_memory_max` stores and systemd enforces, became `infinity` in the agent's cap file: the cgroup was capped while the agent was told there was no cap. It now takes exactly the single-term forms systemd's `parse_size` does: no suffix, `B`, or K/M/G/T/P/E, case-sensitive. Lowercase units and `4GB`, which systemd refuses and so leaves the unit uncapped, read as `infinity` too. A test runs every suffix form the validator accepts through it and checks the stored value parses to the right byte count. Part of #5081 (slice S0-2). --- hive-c0re/src/agent_config/resource_limits.rs | 71 +++++++++++++++---- hive-c0re/src/lifecycle/host_config.rs | 4 ++ 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/hive-c0re/src/agent_config/resource_limits.rs b/hive-c0re/src/agent_config/resource_limits.rs index 4a173c89..fd68b78a 100644 --- a/hive-c0re/src/agent_config/resource_limits.rs +++ b/hive-c0re/src/agent_config/resource_limits.rs @@ -123,9 +123,12 @@ fn resolve(limits: &AgentLimits, hive_cpu_quota: &str, hive_memory_max: &str) -> } /// Parse a systemd `MemoryMax=`-style byte-size value (`"4G"`, `"512M"`, -/// a bare byte count, optionally with a decimal like `"1.5G"`) into a -/// raw byte count. systemd's `K`/`M`/`G`/`T` suffixes are IEC binary -/// (1024-based), not decimal — this matches. Pure integer arithmetic +/// `"1048576B"`, a bare byte count, optionally with a decimal like `"1.5G"`) +/// into a raw byte count, accepting exactly the single-term forms systemd's +/// `parse_size` does: a `K`/`M`/`G`/`T`/`P`/`E` suffix, IEC binary +/// (1024-based), a `B` suffix, or none. Suffixes are case-sensitive, and a +/// redundant `B` after a multiplier (`4GB`) is rejected, as systemd rejects +/// both and then leaves the unit uncapped. Pure integer arithmetic /// throughout (via `u128` headroom) rather than `f64`: a `MemoryMax=` /// value is always a small non-negative decimal (enforced by /// [`is_plain_number`] upstream in [`validate_memory_max`]), so floats @@ -137,14 +140,18 @@ pub fn parse_bytes(value: &str) -> Option { if value == "infinity" || value.ends_with('%') { return None; } - let (mantissa, exponent) = [('K', 1u32), ('M', 2), ('G', 3), ('T', 4)] - .into_iter() - .find_map(|(suffix, exp)| { - value - .strip_suffix([suffix, suffix.to_ascii_lowercase()]) - .map(|m| (m, exp)) - }) - .unwrap_or((value, 0)); + let (mantissa, exponent) = [ + ('E', 6u32), + ('P', 5), + ('T', 4), + ('G', 3), + ('M', 2), + ('K', 1), + ('B', 0), + ] + .into_iter() + .find_map(|(suffix, exp)| value.strip_suffix(suffix).map(|m| (m, exp))) + .unwrap_or((value, 0)); let scale = u128::from(1024u64.checked_pow(exponent)?); let (int_part, frac_part) = mantissa.split_once('.').unwrap_or((mantissa, "")); let whole: u128 = int_part.parse().ok()?; @@ -466,8 +473,14 @@ mod tests { parse_bytes("1.5G"), Some(1024 * 1024 * 1024 + 512 * 1024 * 1024) ); - // Lowercase suffixes accepted, matching `normalized_byte_size`. - assert_eq!(parse_bytes("4g"), Some(4 * 1024 * 1024 * 1024)); + assert_eq!(parse_bytes("1048576B"), Some(1_048_576)); + assert_eq!(parse_bytes("3P"), Some(3 << 50)); + assert_eq!(parse_bytes("2E"), Some(2 << 60)); + assert_eq!( + parse_bytes("16E"), + None, + "past u64, which systemd refuses too" + ); } #[test] @@ -478,8 +491,38 @@ mod tests { #[test] fn parse_bytes_rejects_garbage() { - for v in ["", "abc", "-8G", "8Gi"] { + // systemd's suffixes are case-sensitive and take no `B` after a + // multiplier, so it leaves a unit with any of these uncapped. + for v in [ + "", "abc", "-8G", "8Gi", "4g", "4k", "1024b", "4GB", "4MB", "B", ".5G", + ] { assert_eq!(parse_bytes(v), None, "{v} should not parse"); } } + + #[test] + fn every_size_the_validator_stores_has_a_byte_count() { + for (input, bytes) in [ + ("1048576", 1_048_576), + ("1048576B", 1_048_576), + ("1048576b", 1_048_576), + ("4096K", 4096 << 10), + ("4096kb", 4096 << 10), + ("512M", 512 << 20), + ("512mB", 512 << 20), + ("4G", 4 << 30), + ("4g", 4 << 30), + ("4GB", 4 << 30), + ("1.5G", 3 << 29), + ("2T", 2 << 40), + ("2tb", 2 << 40), + ] { + let stored = validate_memory_max(input).expect(input); + assert_eq!( + parse_bytes(&stored), + Some(bytes), + "{input} stored as {stored}" + ); + } + } } diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index 0552b1fd..008d20cf 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -947,6 +947,8 @@ mod tests { fn a_cap_is_read_the_way_systemd_reads_memory_max() { for (memory_max, cap) in [ ("4G", "4294967296"), + ("1048576B", "1048576"), + ("3P", "3377699720527872"), ("infinity", "infinity"), ("50%", "8192004096"), ("33.33%", "5460787200"), @@ -954,6 +956,8 @@ mod tests { ("100%", "16384012288"), // systemd ignores these and the unit runs uncapped. ("0", "infinity"), + ("4g", "infinity"), + ("4GB", "infinity"), ("0%", "infinity"), ("150%", "infinity"), ("12.345%", "infinity"),