Skip to content

Linux load is measured against a cgroup v2 CPU or memory limit and marked limit #532

Closed

Description

@V3RON

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]
Loading

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"]
Loading

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/cgroup joined with that path; 0::/ means /sys/fs/cgroup itself.
    • A path with a .. segment lies outside Simlock's cgroup namespace. Then only /sys/fs/cgroup itself 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.
  • Every file may end with one newline, as the kernel writes it. The grammars below allow it, and nothing else after the value.
  • cpu.max grammar. It must be exactly <quota> <period>: max or 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_usec from cpu.stat.
    • Total is elapsed time × the limit's cores, in microseconds, from a nowMicros() input. The factory passes Number(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 parsable usage_usec, leaves CPU out. It does not fall back to the machine's figure.
  • Memory limit. memory.max must be a non-negative integer or max, with an optional newline. A limit applies when the lowest is below MemTotal, or when /proc/meminfo cannot be read and the comparison cannot be made.
    • Total is the limit.
    • Available is total − (memory.current − inactive_file from memory.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 no inactive_file line, 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 cores or limited changes.

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.md rule 9: the files are read through the Filesystem port.
  • 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.max quota over its period, and CPU time from cpu.stat against elapsed time, as limited
  • the Linux probe uses /sys/fs/cgroup itself for a 0::/ 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/cgroup for 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.max in the inputs table, and max, 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.stat is missing or has no usage_usec
  • the Linux probe reports memory total as the limit, and available as the limit minus memory.current plus inactive_file, floored at 0, as limited
  • the Linux probe reports swap used as memory.swap.current under a memory limit, and 0 when that file is missing
  • the Linux probe reports the machine's memory, not limited, for memory.max max, a value at or above MemTotal, or an unparsable file
  • the Linux probe leaves memory out under a memory limit when memory.current or inactive_file cannot be read, and keeps the limit when /proc/meminfo cannot be read
  • the Linux probe decides the CPU and memory limits independently
  • the Linux probe reports the machine's figures when /proc/self/cgroup has no 0:: 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/cgroup to 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 nowMicros advances by about one million per second of process.hrtime
  • on Linux (skipped elsewhere), the production Linux probe returns CPU counters, memory and disk from the test machine's real /proc and /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

Metadata

Metadata

Assignees

No one assigned

    Labels

    task:readySpec checked. An agent may implement it once its Depends on allow.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions