carlos / platform Public

agent: exec-backed instances get per-instance metrics (issue #118)

The metrics sampler walked unit cgroups only, so an exec-backed instance —
a direct child of carlos agent, inside the agent's own cgroup — never
reported CPU/memory: its app page read 'asleep — no recent readings' even
during long awake stretches.

The activator now exposes its awake host→pid map (AwakeExecPids; only
awake, unexited, pid-bearing procs — unitProc has no pid and is naturally
excluded), and the sampler reads /proc/<pid>/{stat,status,io} for each,
publishing under the same carlos-app@<host>.service key the console's
owner allowlist already matches. Wire shape and console are untouched:
Sample.Units is a string-keyed map, and an exec-backed host has no real
unit of that name to collide with (cgroup wins if one ever appears).

New pure parsers in internal/metrics (USER_HZ ticks → usec so the reading
feeds UnitCPUFrac's existing delta/reset machinery; VmRSS/VmHWM; pid io),
each field failing toward nil per the probe convention. Known limits per
the design doc: one process per reading (no grandchildren), sidecars not
counted, cgroup path unchanged for unit tenants.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ae795aeba9ecc737d39cf0aa27a9c1a12a0d7bcc parent 12633cb
10 files changed, +459 −15
  • docs/superpowers/specs/2026-08-08-exec-instance-metrics-design.md +6 −3
  • internal/activator/activator.go +29 −0
  • internal/activator/activator_test.go +74 −1
  • internal/activator/backend_exec.go +8 −0
  • internal/host/agent.go +8 −1
  • internal/host/metrics_loop.go +17 −6
  • internal/host/metrics_probes.go +75 −4
  • internal/host/metrics_probes_test.go +58 −0
  • internal/metrics/pidparse.go +101 −0
  • internal/metrics/pidparse_test.go +83 −0
diff --git a/docs/superpowers/specs/2026-08-08-exec-instance-metrics-design.md b/docs/superpowers/specs/2026-08-08-exec-instance-metrics-design.md
index 96edb5903..f48fd4c55 100644
--- a/docs/superpowers/specs/2026-08-08-exec-instance-metrics-design.md
+++ b/docs/superpowers/specs/2026-08-08-exec-instance-metrics-design.md
@@ -121,9 +121,12 @@ zeros.
- `internal/activator`: `AwakeExecPids` over the fake-backend state
machine — awake pid-bearing procs listed; waking/stopping/exited/
pid-less excluded.
-- `internal/host`: merge-function test (cgroup+exec compose, collision
- precedence, both-empty → nil); loop-level test that a sample tick
- carries exec readings through to `Sample.Units`.
+- `internal/host`: keying test (exec readings publish under
+ `carlos-app@<host>.service` — get this wrong and readings match
+ nothing) and merge test (cgroup+exec compose, collision precedence,
+ both-empty → nil). The tick→`Sample.Units` flow is already pinned by
+ the existing fixture tick tests; the composed production probe itself
+ follows the package's untested-seam convention.
## Deploy
diff --git a/internal/activator/activator.go b/internal/activator/activator.go
index 9014bfe37..5500cee25 100644
--- a/internal/activator/activator.go
+++ b/internal/activator/activator.go
@@ -710,6 +710,35 @@ func (a *Service) beginStopLocked(e *actEntry) {
go a.stop(e)
}
+// AwakeExecPids reports the pid of every awake exec-backed child, keyed by
+// route host — the metrics sampler's seam (issue #118): an exec child runs
+// inside the agent's own cgroup, invisible to the per-unit cgroup walk, so
+// its per-instance attribution has to read /proc/<pid> instead, and only
+// the activator knows which pids those are. Included entries are awake,
+// not yet exited, and expose a pid: waking has no stable process yet,
+// stopping is on its way out, and a unit-backed tenant's proc (systemd-
+// driven, no Pid method) already reports through its own unit cgroup. Nil
+// when nothing qualifies — no readings, not an empty set of them.
+func (a *Service) AwakeExecPids() map[string]int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ var out map[string]int
+ for _, e := range a.entries {
+ if e.state != actAwake || e.proc.Exited() {
+ continue
+ }
+ p, ok := e.proc.(interface{ Pid() int })
+ if !ok {
+ continue
+ }
+ if out == nil {
+ out = make(map[string]int)
+ }
+ out[e.host] = p.Pid()
+ }
+ return out
+}
+
// Owns reports whether host has a live entry in ANY state — waking,
// awake, or stopping. This is the external-socket advisory's predicate
// (issue #17): while an entry exists, whatever is serving the host's
diff --git a/internal/activator/activator_test.go b/internal/activator/activator_test.go
index e861d928a..6afbbfa45 100644
--- a/internal/activator/activator_test.go
+++ b/internal/activator/activator_test.go
@@ -160,6 +160,11 @@ type fakeBackend struct {
// target).
execVerFn func() string
+ // pidByHost, when a host is present, makes Start() return a pid-BEARING
+ // proc (pidProc) for it — modeling ExecBackend's childProc. Hosts absent
+ // from the map get the plain pid-less fakeProc, modeling unitProc.
+ pidByHost map[string]int
+
// Sidecar co-spawn. sidecarStaged is HasSidecar's answer (false by
// default, so every pre-co-spawn test doubles as the "host without a
// sidecar spawns none" case); sidecarErr injects spawn failures.
@@ -215,7 +220,7 @@ func (b *fakeBackend) Seed(_ context.Context, rt registry.Route) error {
return b.seedErr
}
-func (b *fakeBackend) Start(context.Context, registry.Route) (Proc, string, error) {
+func (b *fakeBackend) Start(_ context.Context, rt registry.Route) (Proc, string, error) {
b.mu.Lock()
b.starts++
b.order = append(b.order, "start:instance")
@@ -224,6 +229,7 @@ func (b *fakeBackend) Start(context.Context, registry.Route) (Proc, string, erro
startErr := b.startErr
proc := &fakeProc{label: "instance", rec: b}
b.procs = append(b.procs, proc)
+ pid, hasPid := b.pidByHost[rt.Host]
b.mu.Unlock()
if startErr != nil {
return nil, "", startErr
@@ -235,9 +241,22 @@ func (b *fakeBackend) Start(context.Context, registry.Route) (Proc, string, erro
if fn != nil {
ver = fn()
}
+ if hasPid {
+ return &pidProc{fakeProc: proc, pid: pid}, ver, nil
+ }
return proc, ver, nil
}
+// pidProc is fakeProc plus a pid — the shape ExecBackend's childProc has,
+// versus unitProc's pid-less one. AwakeExecPids distinguishes the two by
+// the optional Pid() interface, so the fakes must too.
+type pidProc struct {
+ *fakeProc
+ pid int
+}
+
+func (p *pidProc) Pid() int { return p.pid }
+
func (b *fakeBackend) NextDue(context.Context, registry.Route) (int64, error) {
b.mu.Lock()
defer b.mu.Unlock()
@@ -3248,3 +3267,57 @@ func TestExecBackendDoesNotNormalizeSquatterSocket(t *testing.T) {
t.Fatalf("squatter socket mode after Start = %o, want its own 600 — a socket our child did not bind is never ours to widen", fi.Mode().Perm())
}
}
+
+// AwakeExecPids is the metrics sampler's seam (issue #118): the host→pid map
+// of awake exec children. Pid-less procs (unit-backed tenants — they report
+// via their own unit cgroup), dead children, and stopping entries must all
+// be excluded — a pid the sampler reads must belong to a process the
+// activator still vouches for.
+func TestAwakeExecPids(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ b.pidByHost = map[string]int{
+ "one.example.com": 4242,
+ "three.example.com": 4343,
+ }
+
+ if got := a.AwakeExecPids(); got != nil {
+ t.Fatalf("nothing awake: got %v, want nil", got)
+ }
+
+ for _, host := range []string{"one.example.com", "two.example.com", "three.example.com"} {
+ release, err := a.beginRequest(context.Background(), testRoute(host))
+ if err != nil {
+ t.Fatalf("beginRequest %s: %v", host, err)
+ }
+ release()
+ }
+
+ // two (pid-less, modeling a unit tenant) is excluded; one and three report.
+ got := a.AwakeExecPids()
+ if len(got) != 2 || got["one.example.com"] != 4242 || got["three.example.com"] != 4343 {
+ t.Fatalf("got %v, want one=4242 three=4343 only", got)
+ }
+
+ // A stopping entry is excluded the moment it flips, not once the stop
+ // finishes: hold three's stop open on its gate and look while it drains.
+ pThree := b.procs[2]
+ pThree.mu.Lock()
+ pThree.stopGate = make(chan struct{})
+ pThree.mu.Unlock()
+ if !a.Bounce("three.example.com") {
+ t.Fatal("Bounce three: not awake?")
+ }
+ if got := a.AwakeExecPids(); len(got) != 1 || got["one.example.com"] != 4242 {
+ t.Fatalf("with three stopping: got %v, want one=4242 only", got)
+ }
+ close(pThree.stopGate)
+
+ // A dead-but-unreaped child is excluded even before a sweep notices it.
+ pOne := b.procs[0]
+ pOne.mu.Lock()
+ pOne.dead = true
+ pOne.mu.Unlock()
+ if got := a.AwakeExecPids(); got != nil {
+ t.Fatalf("with one dead: got %v, want nil", got)
+ }
+}
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go
index c8514652e..e8c2a7e66 100644
--- a/internal/activator/backend_exec.go
+++ b/internal/activator/backend_exec.go
@@ -730,6 +730,14 @@ type childProc struct {
purge *edge.PurgeListener // nil if b.Cache was nil or ListenPurge failed
}
+// Pid reports the spawned child's OS pid — the seam AwakeExecPids reads so
+// the metrics sampler can find this child under /proc (issue #118: an exec
+// child lives in the agent's own cgroup, so per-pid reads are its only
+// per-instance attribution). cmd.Process is set once Start succeeded, which
+// is the only way a childProc is ever constructed, and stays valid for the
+// struct's life.
+func (p *childProc) Pid() int { return p.cmd.Process.Pid }
+
func (p *childProc) Exited() bool {
select {
case <-p.done:
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 84e7e0f50..fe0e848c3 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -162,7 +162,14 @@ func RunAgent(args []string) {
go statusLoop(reg)
go domainsLoop(reg, peekAcmeCache(args))
go instancesLoop(reg)
- go metricsLoop()
+ // The metrics loop reads awake exec children from the activator (issue
+ // #118: they live in the agent's own cgroup, invisible to the unit walk);
+ // with no activator armed there are no exec children to read.
+ var execPids func() map[string]int
+ if svc != nil {
+ execPids = svc.AwakeExecPids
+ }
+ go metricsLoop(execPids)
go configSyncLoop(reg, svc, dd)
go configGenLoop(reg, svc, dd)
go socketAdvisoryLoop(reg, svc)
diff --git a/internal/host/metrics_loop.go b/internal/host/metrics_loop.go
index a0de7bd66..4c237e6d7 100644
--- a/internal/host/metrics_loop.go
+++ b/internal/host/metrics_loop.go
@@ -617,14 +617,23 @@ func httpPostWebhook(url string, body []byte) error {
// realSampleProbes wires sampleProbes to the actual OS/cgroup readers in
// metrics_probes.go and probes.go — the production seam metricsLoop uses,
// never called from a test (host_test.go's convention: tests inject
-// fixtures, never touch /proc or a syscall).
-func realSampleProbes() sampleProbes {
+// fixtures, never touch /proc or a syscall). execPids is the activator's
+// awake exec-children source (Service.AwakeExecPids); nil — no activator
+// armed, e.g. plain `carlos edge` — keeps the units probe cgroup-only,
+// exactly the pre-#118 behavior.
+func realSampleProbes(execPids func() map[string]int) sampleProbes {
+ units := realUnitReadings
+ if execPids != nil {
+ units = func() map[string]unitRaw {
+ return mergeUnitReadings(realUnitReadings(), execInstanceReadings(execPids(), readExecInstanceRaw))
+ }
+ }
return sampleProbes{
procStat: readProcStat,
loadavg: readLoadAvg,
memFrac: readMemFrac,
diskFrac: readDiskFrac,
- units: realUnitReadings,
+ units: units,
now: time.Now,
}
}
@@ -698,8 +707,10 @@ func loadAlertState(path, box string) metrics.AlertState {
// still a valid deployment" shape every other optional loop in this package
// uses. Flush runs every 5th tick (5 minutes at the 60s tick cadence);
// alertTick runs every tick, since a threshold breach deserves faster
-// detection than a metrics flush does.
-func metricsLoop() {
+// detection than a metrics flush does. execPids feeds the units probe the
+// activator's awake exec children (nil when no activator is armed) — see
+// realSampleProbes.
+func metricsLoop(execPids func() map[string]int) {
// This loop is always on and shares a process with the agent that serves
// this box's traffic, so a panic in it — an unreadable cgroup shape, a
// nil no one expected — must not take the box down with it. Monitoring
@@ -732,7 +743,7 @@ func metricsLoop() {
log.Printf("agent: metrics loop armed (60s ticks, 5min flush), publishing to bucket %s", bucket)
sm := &sampler{}
- probes := realSampleProbes()
+ probes := realSampleProbes(execPids)
tick := 0
for {
time.Sleep(60 * time.Second)
diff --git a/internal/host/metrics_probes.go b/internal/host/metrics_probes.go
index 899247438..ef0ffc744 100644
--- a/internal/host/metrics_probes.go
+++ b/internal/host/metrics_probes.go
@@ -4,17 +4,21 @@
// wrapping the pure parsers in internal/metrics — and the readers are
// deliberately untested by the same convention as probes.go readers;
// internal/metrics tests exercise the parsers with fixtures instead. The one
-// exception is listCarlosUnits: it is a directory WALK, not a single read, and
-// which directories it walks is a decision that has already been wrong once
-// (see its doc comment), so it takes its root as an argument and is pinned by
-// metrics_probes_test.go against a temp-dir fixture.
+// exceptions are the functions with decisions in them: listCarlosUnits is a
+// directory WALK, not a single read, and which directories it walks has
+// already been wrong once (see its doc comment), so it takes its root as an
+// argument and is pinned by metrics_probes_test.go against a temp-dir
+// fixture; execInstanceReadings/mergeUnitReadings are pure (keying and
+// precedence for exec-child readings, issue #118) and are pinned there too.
package host
import (
"os"
"path/filepath"
+ "strconv"
"strings"
+ "github.com/carlosframework/platform/internal/activator"
"github.com/carlosframework/platform/internal/metrics"
)
@@ -133,6 +137,73 @@ type unitRaw struct {
writeBytes *int64
}
+// readExecInstanceRaw reads one awake exec child's resource usage from
+// /proc/<pid>/{stat,status,io} — the per-pid counterpart of readUnitRaw for
+// instances that have no unit cgroup to read (issue #118: an exec-backed
+// instance is a direct child of `carlos agent`, inside the agent's own
+// cgroup, so the unit walk above can never see it). Same contract as
+// readUnitRaw: each field reads and fails independently, toward nil — a pid
+// that vanished mid-read (raced its own exit) contributes nils, never zeros.
+// Known limit, documented in the design: a pid reading covers ONE process,
+// not a subtree — an app that forks workers under-reports here where a
+// cgroup would not.
+func readExecInstanceRaw(pid int) unitRaw {
+ dir := filepath.Join("/proc", strconv.Itoa(pid))
+ raw := unitRaw{}
+ if b, err := os.ReadFile(filepath.Join(dir, "stat")); err == nil {
+ raw.cpuUsec = metrics.ParsePidStatCPUUsec(string(b))
+ }
+ if b, err := os.ReadFile(filepath.Join(dir, "status")); err == nil {
+ raw.memBytes, raw.memPeakBytes = metrics.ParsePidStatusMem(string(b))
+ }
+ if b, err := os.ReadFile(filepath.Join(dir, "io")); err == nil {
+ raw.readBytes, raw.writeBytes = metrics.ParsePidIO(string(b))
+ }
+ return raw
+}
+
+// execInstanceReadings turns the activator's awake host→pid map into unit
+// readings keyed by the SAME carlos-app@<host>.service name the console's
+// owner view matches (activator.UnitName) — which is what makes the whole
+// fix additive: the wire shape (Sample.Units, a string-keyed map) and the
+// console's allowlist both take the new entries as-is. An exec-backed host
+// has no such unit by construction (units are for the edge; instances are
+// the agent's children), so the key cannot collide with a real cgroup
+// reading on this box; mergeUnitReadings still defines the winner in case
+// that ever changes. read is a parameter (readExecInstanceRaw in
+// production) so the keying is testable without /proc.
+func execInstanceReadings(pids map[string]int, read func(pid int) unitRaw) map[string]unitRaw {
+ if len(pids) == 0 {
+ return nil
+ }
+ out := make(map[string]unitRaw, len(pids))
+ for host, pid := range pids {
+ out[activator.UnitName(host)] = read(pid)
+ }
+ return out
+}
+
+// mergeUnitReadings composes the units probe's two sources: the cgroup walk
+// (every systemd-backed CARLOS unit) plus the per-pid readings for awake
+// exec children. On a key collision the cgroup reading wins — it accounts a
+// whole subtree where the pid reading covers one process, so it can only be
+// more complete. Both-empty stays nil (no readings, not an empty set), and
+// a nil cgroup map with live exec children still reports the children — a
+// box whose cgroup walk failed has not stopped running instances.
+func mergeUnitReadings(cgroup, exec map[string]unitRaw) map[string]unitRaw {
+ if len(exec) == 0 {
+ return cgroup
+ }
+ out := make(map[string]unitRaw, len(cgroup)+len(exec))
+ for name, u := range exec {
+ out[name] = u
+ }
+ for name, u := range cgroup {
+ out[name] = u
+ }
+ return out
+}
+
// readUnitRaw reads a unit's resource usage from the cgroup v2 files in
// unitPath — the directory listCarlosUnits reported for that unit, which for
// an instantiated unit is NOT <cgroupSliceDir>/<name>. Each file read failure
diff --git a/internal/host/metrics_probes_test.go b/internal/host/metrics_probes_test.go
index 8ea66188d..bf9d7e443 100644
--- a/internal/host/metrics_probes_test.go
+++ b/internal/host/metrics_probes_test.go
@@ -88,3 +88,61 @@ func TestReadUnitRawReadsFromUnitDir(t *testing.T) {
t.Errorf("absent files must stay nil, got peak=%v r=%v w=%v", raw.memPeakBytes, raw.readBytes, raw.writeBytes)
}
}
+
+// Exec children publish under the SAME carlos-app@<host>.service key the
+// console's owner allowlist matches (activator.UnitName) — that keying is
+// the whole fix for issue #118, so it's pinned here: get it wrong and the
+// readings publish fine but match nothing, silently reopening the gap.
+func TestExecInstanceReadingsKeysByUnitName(t *testing.T) {
+ cpu := int64(5_000_000)
+ got := execInstanceReadings(map[string]int{"lpay.bde.oncarlos.com": 4242}, func(pid int) unitRaw {
+ if pid != 4242 {
+ t.Fatalf("read called with pid %d, want 4242", pid)
+ }
+ return unitRaw{cpuUsec: &cpu}
+ })
+ raw, ok := got["carlos-app@lpay.bde.oncarlos.com.service"]
+ if !ok || len(got) != 1 {
+ t.Fatalf("got keys %v, want exactly carlos-app@lpay.bde.oncarlos.com.service", got)
+ }
+ if raw.cpuUsec == nil || *raw.cpuUsec != cpu {
+ t.Fatalf("reading not carried through: %v", raw.cpuUsec)
+ }
+
+ if execInstanceReadings(nil, nil) != nil {
+ t.Fatal("no pids must be nil, not an empty map")
+ }
+}
+
+func TestMergeUnitReadings(t *testing.T) {
+ v := func(n int64) unitRaw { return unitRaw{cpuUsec: &n} }
+ deref := func(u unitRaw) int64 { return *u.cpuUsec }
+
+ // Both sources present: union, cgroup wins the collision (it accounts a
+ // whole subtree; the pid reading is one process).
+ got := mergeUnitReadings(
+ map[string]unitRaw{"carlos-edge.service": v(1), "carlos-app@both.example.com.service": v(2)},
+ map[string]unitRaw{"carlos-app@x.example.com.service": v(3), "carlos-app@both.example.com.service": v(4)},
+ )
+ if len(got) != 3 {
+ t.Fatalf("got %d keys, want 3", len(got))
+ }
+ if deref(got["carlos-app@both.example.com.service"]) != 2 {
+ t.Fatal("collision must keep the cgroup reading")
+ }
+ if deref(got["carlos-edge.service"]) != 1 || deref(got["carlos-app@x.example.com.service"]) != 3 {
+ t.Fatal("non-colliding readings must survive from both sides")
+ }
+
+ // A failed cgroup walk (nil) with live exec children still reports them.
+ got = mergeUnitReadings(nil, map[string]unitRaw{"carlos-app@x.example.com.service": v(3)})
+ if len(got) != 1 {
+ t.Fatalf("nil cgroup side: got %v, want the exec reading alone", got)
+ }
+
+ // No exec children: the cgroup map passes through untouched — including
+ // nil staying nil ("no readings", not an empty set of them).
+ if mergeUnitReadings(nil, nil) != nil {
+ t.Fatal("both-nil must stay nil")
+ }
+}
diff --git a/internal/metrics/pidparse.go b/internal/metrics/pidparse.go
new file mode 100644
index 000000000..1bb70e736
--- /dev/null
+++ b/internal/metrics/pidparse.go
@@ -0,0 +1,101 @@
+// /proc/<pid> parsers — the per-process counterpart of the cgroup parsers in
+// parse.go, for instances that have no unit cgroup to read: an exec-backed
+// instance is a direct child of `carlos agent` inside the agent's own cgroup
+// (issue #118), so its readings come from /proc/<pid>/{stat,status,io}
+// instead. Same package contract as everything else here: parse text the
+// caller read, fail toward nil, never touch the OS.
+package metrics
+
+import (
+ "strconv"
+ "strings"
+)
+
+// userHZ is the tick unit of /proc/<pid>/stat's utime/stime fields. The
+// kernel fixes the userspace-visible USER_HZ at 100 on every architecture
+// CARLOS runs on (it is independent of the kernel's internal CONFIG_HZ), so
+// a constant is the honest value — there is no portable syscall to ask for
+// it without cgo.
+const userHZ = 100
+
+// ParsePidStatCPUUsec parses /proc/<pid>/stat and returns utime+stime as CPU
+// microseconds — the same unit as cpu.stat's usage_usec, so the reading
+// feeds UnitCPUFrac's delta machinery unchanged. The comm field (2) is the
+// process name in parentheses and may itself contain spaces and parens, so
+// fields are located from the LAST ')' — counting from the front would
+// misparse any binary with a space in its name. After that anchor the line
+// reads: state ppid pgrp session tty tpgid flags minflt cminflt majflt
+// cmajflt utime stime — utime and stime at offsets 11 and 12. Too few
+// fields or a non-numeric tick count -> nil.
+func ParsePidStatCPUUsec(text string) *int64 {
+ i := strings.LastIndexByte(text, ')')
+ if i < 0 {
+ return nil
+ }
+ fields := strings.Fields(text[i+1:])
+ if len(fields) < 13 {
+ return nil
+ }
+ utime, err1 := strconv.ParseInt(fields[11], 10, 64)
+ stime, err2 := strconv.ParseInt(fields[12], 10, 64)
+ if err1 != nil || err2 != nil {
+ return nil
+ }
+ usec := (utime + stime) * (1e6 / userHZ)
+ return &usec
+}
+
+// ParsePidStatusMem parses /proc/<pid>/status and returns VmRSS (resident
+// set) and VmHWM (its peak) in bytes. RSS rather than anything
+// cache-inclusive on purpose: a per-process reading cannot attribute page
+// cache the way memory.current does, and for "what your app uses" resident
+// memory is the honest number it CAN give. Each field fails toward nil
+// independently — the kernel prints these in kB always, so any other unit
+// suffix means the line is being misread and nils that field rather than
+// guessing a scale.
+func ParsePidStatusMem(text string) (rss, hwm *int64) {
+ for _, line := range strings.Split(text, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) != 3 || fields[2] != "kB" {
+ continue
+ }
+ v, err := strconv.ParseInt(fields[1], 10, 64)
+ if err != nil {
+ continue
+ }
+ bytes := v * 1024
+ switch fields[0] {
+ case "VmRSS:":
+ rss = &bytes
+ case "VmHWM:":
+ hwm = &bytes
+ }
+ }
+ return rss, hwm
+}
+
+// ParsePidIO parses /proc/<pid>/io and returns read_bytes / write_bytes —
+// actual block-device I/O, cumulative since process start, the same
+// semantics as the cgroup io.stat counters. rchar/wchar are deliberately
+// ignored: they count every read/write syscall including cache hits, a
+// different (and much larger) number. Each field fails toward nil
+// independently.
+func ParsePidIO(text string) (rbytes, wbytes *int64) {
+ for _, line := range strings.Split(text, "\n") {
+ key, val, ok := strings.Cut(line, ":")
+ if !ok {
+ continue
+ }
+ v, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64)
+ if err != nil {
+ continue
+ }
+ switch key {
+ case "read_bytes":
+ rbytes = &v
+ case "write_bytes":
+ wbytes = &v
+ }
+ }
+ return rbytes, wbytes
+}
diff --git a/internal/metrics/pidparse_test.go b/internal/metrics/pidparse_test.go
new file mode 100644
index 000000000..85d8dfdd0
--- /dev/null
+++ b/internal/metrics/pidparse_test.go
@@ -0,0 +1,83 @@
+package metrics
+
+import "testing"
+
+func TestParsePidStatCPUUsec(t *testing.T) {
+ // pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt
+ // majflt cmajflt utime stime ... — utime=250 stime=150 ticks at USER_HZ
+ // 100 is 4.0 CPU-seconds = 4_000_000 usec.
+ line := "1234 (lpay) S 1 1234 1234 0 -1 4194560 900 0 0 0 250 150 0 0 20 0 8 0 12345 1000000 500 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 17 3 0 0 0 0 0"
+ v := ParsePidStatCPUUsec(line)
+ if v == nil || *v != 4_000_000 {
+ t.Fatalf("got %v, want 4000000", v)
+ }
+
+ // comm may contain spaces AND parens; everything before the LAST ')' is
+ // name, so the field offsets must be taken from there.
+ tricky := "1234 (my app) (v2)) R 1 1234 1234 0 -1 4194560 900 0 0 0 100 100 0 0 20 0 8 0 12345 1000000 500 0 1 1 0 0 0 0 0 0 0 0 0 0 17 3 0 0 0 0 0"
+ v = ParsePidStatCPUUsec(tricky)
+ if v == nil || *v != 2_000_000 {
+ t.Fatalf("paren-comm: got %v, want 2000000", v)
+ }
+
+ for _, bad := range []string{
+ "",
+ "no parens at all",
+ "1234 (lpay) S 1 2", // too few fields after comm
+ "1234 (lpay) S 1 2 3 4 5 6 7 8 9 10 x 150 0", // non-numeric utime
+ } {
+ if ParsePidStatCPUUsec(bad) != nil {
+ t.Fatalf("garbage %q must be nil", bad)
+ }
+ }
+}
+
+func TestParsePidStatusMem(t *testing.T) {
+ text := "Name:\tlpay\nVmPeak:\t 20000 kB\nVmHWM:\t 9000 kB\nVmRSS:\t 8192 kB\nThreads:\t8\n"
+ rss, hwm := ParsePidStatusMem(text)
+ if rss == nil || *rss != 8192*1024 {
+ t.Fatalf("rss: got %v, want %d", rss, 8192*1024)
+ }
+ if hwm == nil || *hwm != 9000*1024 {
+ t.Fatalf("hwm: got %v, want %d", hwm, 9000*1024)
+ }
+
+ // Each field fails toward nil independently: a kernel thread has no VmRSS
+ // line at all, and a garbled VmHWM must not take VmRSS down with it.
+ rss, hwm = ParsePidStatusMem("Name:\tx\nVmRSS:\t 100 kB\nVmHWM:\tgarbage kB\n")
+ if rss == nil || *rss != 100*1024 || hwm != nil {
+ t.Fatalf("independent fields: got rss=%v hwm=%v", rss, hwm)
+ }
+
+ rss, hwm = ParsePidStatusMem("")
+ if rss != nil || hwm != nil {
+ t.Fatal("empty must be both-nil")
+ }
+
+ // A VmRSS value not in kB (the kernel never emits another unit, so an
+ // unexpected one means we're misreading) must be nil, not a wrong scale.
+ rss, _ = ParsePidStatusMem("VmRSS:\t 100 mB\n")
+ if rss != nil {
+ t.Fatal("non-kB unit must be nil")
+ }
+}
+
+func TestParsePidIO(t *testing.T) {
+ text := "rchar: 999\nwchar: 888\nsyscr: 10\nsyscw: 20\nread_bytes: 4096\nwrite_bytes: 8192\ncancelled_write_bytes: 0\n"
+ r, w := ParsePidIO(text)
+ if r == nil || *r != 4096 || w == nil || *w != 8192 {
+ t.Fatalf("got r=%v w=%v, want 4096/8192", r, w)
+ }
+
+ // Independent nils: rchar/wchar (which count ALL io including cached)
+ // must never stand in for the missing block-io counters.
+ r, w = ParsePidIO("rchar: 999\nread_bytes: 4096\n")
+ if r == nil || *r != 4096 || w != nil {
+ t.Fatalf("independent fields: got r=%v w=%v", r, w)
+ }
+
+ r, w = ParsePidIO("")
+ if r != nil || w != nil {
+ t.Fatal("empty must be both-nil")
+ }
+}