Repository navigation
Linux load is measured against a cgroup v2 CPU or memory limit and marked limit #532
Description
Part of #527.
Scope
On Linux, where a cgroup v2 CPU or memory limit applies to Simlock, the Load line measures that figure against the limit and marks it limit. Two cases: a container with limits, and a systemd service or slice with MemoryMax= / CPUQuota=. Without a limit, the figures stay the whole machine's.
In short
Only the Linux probe changes. The CLI already prints limit when a part is limited (#531).
flowchart LR
CG["/proc/self/cgroup, /sys/fs/cgroup/... (new reads)"] --> P["LinuxHostLoadProbe (changed)"]
P --> S[host-load sampler]
S --> C[simlock status]
Before, in a container limited to 2 CPUs and 8 GiB on a 64-core, 256 GiB machine:
Load: CPU 3% busy (64 cores) · memory 201.4 GiB of 256.0 GiB available · swap 0 B used · disk ...
After:
Load: CPU 61% busy (2 cores, limit) · memory 3.1 GiB of 8.0 GiB available (limit) · swap 0 B used · disk ...
The table assumes a 64-core, 256 GiB machine. "Folder" is Simlock's cgroup folder under /sys/fs/cgroup; "parents" are the folders above it, up to /sys/fs/cgroup. Values are shown without the trailing newline every kernel file ends with; the tests seed them with it.
/proc/self/cgroup |
cpu.max |
memory.max |
CPU | Memory |
|---|---|---|---|---|
0::/docker/abc |
folder 200000 100000 |
folder 8589934592 |
2 cores, limit | 8.0 GiB, limit |
0::/ (cgroup namespace) |
folder 150000 100000 |
folder max |
1.5 cores, limit | machine's |
0::/system.slice/simlock.service |
max 100000 everywhere |
folder 17179869184 |
machine's | 16.0 GiB, limit |
0::/system.slice/simlock.service |
none | parent system.slice 8589934592, folder 17179869184 |
machine's | 8.0 GiB, limit (lowest wins); usage from system.slice |
0::/x |
folder 200000 100000, parent 150000 50000 |
— | 2 cores, limit (2 < 3 cores) | — |
0::/../../system.slice/x (outside the cgroup namespace) |
/sys/fs/cgroup only |
/sys/fs/cgroup only |
as /sys/fs/cgroup says |
as /sys/fs/cgroup says |
0::/x |
folder 1000 100000 |
— | 0.01 cores, limit | — |
0::/x |
folder 1000 1000000 |
— | 0.001 cores, limit (prints <0.01 cores) |
— |
0::/a/b |
folder b and parent a both 200000 100000 |
folder b and parent a both 8589934592 |
2 cores, limit; usage from a |
8.0 GiB, limit; usage from a |
0::/user.slice/x |
files missing | files missing | machine's | machine's |
v1 lines only, no 0:: |
— | — | machine's | machine's |
0::/x |
garbage / 200000 / 200000 0 / -1 100000 |
garbage |
machine's | machine's |
0::/x |
800000 100000 (8 cores, above 4 on a 4-core machine) |
— | machine's | — |
0::/x |
— | 1099511627776 (above MemTotal) |
— | machine's |
0::/x, cpu.stat missing or without usage_usec |
200000 100000 |
— | unavailable |
— |
0::/x, memory.current or memory.stat inactive_file missing |
— | 8589934592 |
— | unavailable |
If this goes wrong, a container shows the host's figures, or a limit shows on bare metal where none is set.
Technical spec
Follow ADR 0028 §2's Linux limits (PR #529).
Modules touched
flowchart LR
F["ports/host-load.ts *: LinuxHostLoadProbe, productionHostLoadProbe"] --> FS["ports/filesystem.ts"]
Only src/ports/host-load.ts changes. The production factory #531 adds there builds the Linux probe for both modes, so src/daemon/main.ts is untouched.
- The cgroup. Simlock's cgroup is the path on the line starting
0::in/proc/self/cgroup. Its folder is/sys/fs/cgroupjoined with that path;0::/means/sys/fs/cgroupitself.- A path with a
..segment lies outside Simlock's cgroup namespace. Then only/sys/fs/cgroupitself is read, so the walk never leaves it. - Limit files are read in that folder and in each parent folder up to and including
/sys/fs/cgroup. - For each of CPU and memory, the lowest valid limit along the path wins. CPU limits compare as cores (quota ÷ period), not as raw quotas. On a tie, the topmost folder holding that limit wins, because its limit caps every folder below it.
- Usage files (
cpu.stat,memory.current,memory.stat,memory.swap.current) are read in the folder that holds the winning limit. That folder's usage is what the limit caps, siblings included.
- A path with a
- Every file may end with one newline, as the kernel writes it. The grammars below allow it, and nothing else after the value.
cpu.maxgrammar. It must be exactly<quota> <period>:maxor a positive integer, one space, a positive integer, then an optional newline. Anything else is no limit at that level. A valid quota gives quota ÷ period cores.- CPU limit. It applies when the lowest quota gives fewer cores than the machine has (
os.cpus().length, the cores the probe already reads).- Busy is
usage_usecfromcpu.stat. - Total is elapsed time × the limit's cores, in microseconds, from a
nowMicros()input. The factory passesNumber(process.hrtime.bigint() / 1000n). - The result is
limited: true. Only the ratio between two reads matters, so the sampler is unchanged. - A missing
cpu.stat, or one with no parsableusage_usec, leaves CPU out. It does not fall back to the machine's figure.
- Busy is
- Memory limit.
memory.maxmust be a non-negative integer ormax, with an optional newline. A limit applies when the lowest is belowMemTotal, or when/proc/meminfocannot be read and the comparison cannot be made.- Total is the limit.
- Available is total − (
memory.current−inactive_filefrommemory.stat), floored at 0. - Swap used is
memory.swap.current, or 0 when that file is missing. - The result is
limited: true. - A missing or unparsable
memory.current, or noinactive_fileline, leaves memory out.
- CPU and memory decide their limits independently.
- The cgroup and its limits are read on every read, so a limit changed at runtime shows on the next one. The sampler already drops CPU for one reading when
coresorlimitedchanges.
Contract and event changes
None. limited is already on the wire.
Other code on the same state
None. Only this probe reads /proc/self/cgroup or /sys/fs/cgroup; a search for cgroup across src finds nothing else.
Rules in play
architecture.mdrule 9: the files are read through theFilesystemport.testing.md: every row of the inputs table is a test case.
Tests
The seam is LinuxHostLoadProbe with a MemoryFilesystem seeded with /proc and /sys/fs/cgroup files, alongside #531's Linux probe tests in src/ports/host-load.test.ts.
- the Linux probe reports cores as the
cpu.maxquota over its period, and CPU time fromcpu.statagainst elapsed time, as limited - the Linux probe uses
/sys/fs/cgroupitself for a0::/line - the Linux probe takes the lowest CPU and the lowest memory limit across Simlock's folder and its parents, comparing CPU limits as cores
- the Linux probe reads usage from the folder that holds the winning limit, not from Simlock's own folder, and from the topmost of two folders tied on the lowest limit
- the Linux probe reads only
/sys/fs/cgroupfor a cgroup path with a..segment - the Linux probe accepts every file with the kernel's trailing newline, and all its table inputs are seeded with it
- the Linux probe treats every malformed
cpu.maxin the inputs table, andmax, as no limit at that level - the Linux probe reports the machine's CPU, not limited, for a quota at or above the machine's cores
- the Linux probe leaves CPU out under a CPU limit when
cpu.statis missing or has nousage_usec - the Linux probe reports memory total as the limit, and available as the limit minus
memory.currentplusinactive_file, floored at 0, as limited - the Linux probe reports swap used as
memory.swap.currentunder a memory limit, and 0 when that file is missing - the Linux probe reports the machine's memory, not limited, for
memory.maxmax, a value at or aboveMemTotal, or an unparsable file - the Linux probe leaves memory out under a memory limit when
memory.currentorinactive_filecannot be read, and keeps the limit when/proc/meminfocannot be read - the Linux probe decides the CPU and memory limits independently
- the Linux probe reports the machine's figures when
/proc/self/cgrouphas no0::line or cannot be read - the Linux probe shows a limit seeded between two reads on the second read, drops one removed between reads, and follows
/proc/self/cgroupto a new folder between reads - busy % under a CPU limit is the cgroup's usage divided by elapsed time × the limit's cores, from two reads through the sampler: 6.1 s of usage over 10 s on a 2-core limit is 30.5%
- the production factory's
nowMicrosadvances by about one million per second ofprocess.hrtime - on Linux (skipped elsewhere), the production Linux probe returns CPU counters, memory and disk from the test machine's real
/procand/sys
Assumption: busy % under a CPU limit is usage ÷ (elapsed × the limit's cores), the share of the CPU time available, as "busy %" is defined in the feature; the issue's "61%" example for 6.1 s over 10 s on 2 cores was an arithmetic slip (decided in delivery, pending the maintainer). Tests line: the sampler test above pins 30.5%.
Assumption: in the table test for a CPU limit that is accepted, the folder holding the limit also has cpu.stat, so usage is read there and cores: 2, limited: true is reported; with no cpu.stat there, CPU is left out, as the missing-cpu.stat test pins (decided in delivery, pending the maintainer).
Done when
- Every test above is green, on Linux CI. Each row of the inputs table is one of their cases.
Out of scope
- cgroup v1.
- macOS (no cgroups).
- Showing a limit's source (container or service).
Depends on
Approval
- Approved for delivery
Written by an agent.
Generated by Claude Code
Activity
feat(ports): Linux host load probe measures against a cgroup v2 CPU o…
refactor(host-load): split the Linux probe's limit parsing and machin…
refactor(host-load): one helper picks the lower limit (#532)
test(host-load): audit probes — v1-only cgroup file, a literal .. pat…
test(host-load): cover the 0.001-core row, the equal-to-machine cases…
test(host-load): a lower limit in Simlock's folder beats a parent's; …
feat(host-load): Linux load is measured against a cgroup v2 limit (#565)