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).
95 lines
4.3 KiB
Nix
95 lines
4.3 KiB
Nix
# 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; `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
|
|
# 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`).
|
|
# `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
|
|
# `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` and
|
|
# `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.
|
|
readCap = noCap: ''
|
|
bytes=
|
|
[ -r /run/hive/memory-max-bytes ] && read -r bytes < /run/hive/memory-max-bytes
|
|
case "$bytes" in
|
|
"" | 0* | *[!0-9]* | ????????????????*) ${noCap} ;;
|
|
esac
|
|
'';
|
|
|
|
envGenerator = pkgs.writeShellScript "hyperhive-memory-cap-env" ''
|
|
${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 "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})
|
|
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;
|
|
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";
|
|
}
|