@nredd/pi-resource-sampler 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Redd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
6
+ associated documentation files (the "Software"), to deal in the Software without restriction,
7
+ including without limitation the rights to use, copy, modify, merge, publish, distribute,
8
+ sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial
12
+ portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
15
+ NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
16
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
17
+ OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,103 @@
1
- # Temporary Holding Version
1
+ # Resource sampler
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pi-independent, dependency-free ESM library for passive host resource dashboards and snapshots.
4
+ `@nredd/pi-resource-sampler` is not a Pi extension. No tools, timers, hooks, output or policy are
5
+ registered. Runs on Node >=22.14 and Bun.
6
+
7
+ Upgrade from `0.1.0`, which is unsafe: on Darwin it reported `ps -c -o comm`, the basename of argv[0],
8
+ so a launcher-chosen argv[0] or Node `process.title` (e.g. `npm exec <package> mcp`) leaked into
9
+ `processes[].name`. On Bun, host `cpu` read lazily populated `os.cpus()` objects, so the second sample
10
+ was `warming` and the third could be a fictional 0 or 100. `0.1.1` keeps the same TypeScript interface.
11
+
12
+ ```ts
13
+ import { createResourceSampler } from '@nredd/pi-resource-sampler';
14
+ const sampler = createResourceSampler();
15
+ try {
16
+ const snapshot = await sampler.sample();
17
+ // Caller schedules subsequent samples and renders typed metrics.
18
+ } finally {
19
+ await sampler.dispose();
20
+ }
21
+ ```
22
+
23
+ `sample(signal?)` returns `ResourceSnapshot` v1. Concurrent calls reject. Cancellation rejects and
24
+ awaits cleanup; `dispose()` aborts and awaits any active sample, is idempotent, and forbids future
25
+ samples. Importing/constructing the library does not collect data. Each instance owns its baseline.
26
+
27
+ Semantics:
28
+
29
+ - `cpu`: aggregate host CPU counter delta, 0..100. Core count/model changes, individual counter
30
+ rollback, initial samples, invalid intervals and counters that have not advanced are `warming`,
31
+ never fabricated zero. Uses Node's native `os.cpus()` counters, not load average.
32
+ - `processes[].cpu`: Linux user+system counter delta divided by monotonic elapsed milliseconds.
33
+ 100 means one logical core, multi-threaded processes can exceed 100. Linux `CLK_TCK` is read
34
+ once with fixed `/usr/bin/getconf CLK_TCK`; no assumed tick rate or RSS page size.
35
+ - Linux identity includes the exact `/proc/PID/stat` start tick string. Read stat/exe/status/stat and
36
+ reject identity changes during RSS collection. Missing or inaccessible processes are skipped and
37
+ invalidate their old baselines. New/reused PIDs and counter resets warm rather than showing zero.
38
+ - `cpu` copies every `os.cpus()` counter when sampled. Bun returns lazy placeholders that read native
39
+ counters on first property access; an unread stored baseline would describe a later instant.
40
+ - Darwin uses only `/bin/ps -A -o pid=,ppid=,lstart=,time=,rss=,ucomm=` with `LC_ALL=C`.
41
+ Actual macOS observation: `time` prints hundredths (e.g. `185:32.40`), but `lstart` prints only
42
+ seconds (`Sat Oct 3 01:49:05 2026`). That cannot rule out PID reuse within one second, so Darwin
43
+ process interval CPU is explicitly `unavailable`. No decaying `%cpu` is relabelled as an interval.
44
+ RSS/name/parent PID remain useful. No native helper until this limitation justifies one separately.
45
+ - `wallTimeMs` labels the start; `monotonicTimeMs` controls intervals. Per-process reads have their own
46
+ monotonic timestamps internally; the scan is not atomic. `durationMs` includes collection.
47
+ - `memory.freeBytes` means free, NOT available or pressure. `loadAverage` is load, NOT percent.
48
+ Linux memory PSI preserves native `some`/optional `full`, avg10/60/300 percentages and cumulative
49
+ stall microseconds. Missing/invalid PSI is unavailable. Other platforms explicitly have no PSI.
50
+ - `processes[].name` is an executable basename from kernel metadata. It excludes argv, environment,
51
+ process title and Linux comm, but it is still untrusted metadata that the process or its launcher can
52
+ choose (see the last item). `""` means unavailable, not a process called nothing:
53
+ - Darwin: `ucomm`, which `ps` prints from kinfo `p_comm`. XNU writes it only at exec, from the
54
+ looked-up executable file name, truncated to 16 bytes (`Google Chrome He`). `ps -c -o comm` is
55
+ argv[0] and is NOT used.
56
+ - Linux: basename of `readlink /proc/PID/exe` (` (deleted)` marker removed), read between the two
57
+ identity-checked stat reads. The kernel gates that link by ptrace read access, so other users'
58
+ processes are usually `""` without privileges. `/proc/PID/stat` comm is NOT used:
59
+ `prctl(PR_SET_NAME)`, `/proc/self/comm` and Node's `process.title` rewrite it. Kernel threads
60
+ have no `VmRSS`, so they are skipped (`process-unreadable-or-changed`) and never named.
61
+ - Whoever names, links or copies the executable file chooses the name. On Linux a process can also
62
+ exec a `memfd_create` file it named itself (`memfd:<name>`), and a privileged one can replace its
63
+ exe link with `prctl(PR_SET_MM_EXE_FILE)`. Names are bounded to 128 code points with control
64
+ characters replaced, no user names, paths or agent attribution are collected, and consumers must
65
+ treat them as untrusted display text, never execute or interpret them.
66
+
67
+ Bounds:
68
+
69
+ - Default scan budget 250ms, 2048 PIDs, 2MiB captured bytes; options allow at most 5s, 16384 PIDs,
70
+ 16MiB. Linux proc files read sequentially (concurrency one), at most 8KiB each. Directory iteration
71
+ stops at the budget; Darwin output stops at the PID/output budget and reaps the fixed helper.
72
+ - `/proc/PID/exe` is a `readlink`, which Node and Bun cannot cap: each call copies up to PATH_MAX
73
+ (4096) bytes into a fixed runtime buffer before the length is known. That fixed per-call syscall
74
+ copy can exceed the remaining budget; the logical budget cannot. A target longer than the remainder
75
+ is charged the remainder and its name dropped (`""`, `byte-limit`), so `bytesRead <= maxBytes`. A
76
+ target filling the 4096-byte buffer may be truncated: Node returns it silently and the name is
77
+ dropped too (`file-byte-limit`); Bun refuses it with `ENAMETOOLONG`, so the name is `""`.
78
+ - `coverage.complete`, `enumerated`, `sampled`, `skipped`, `bytesRead` and reasons describe partial
79
+ coverage explicitly. `enumerated` is encountered rows, not total processes when partial. Truncation
80
+ is not a host-wide top-process ranking. OS enumeration order may favor the same subset each time.
81
+ - Deadline is a monotonic admission/child-kill bound, not a real-time guarantee: event-loop scheduling,
82
+ native `os.cpus()` and in-flight filesystem calls cannot be preempted. Cleanup is always awaited.
83
+ Helpers spawn no descendants, use no shell, stdin ignored, fixed environment, bounded stdout/stderr,
84
+ immediate SIGKILL on abort/deadline/cap, and resolve only on close. No persistent children or workers.
85
+ - Unsupported platforms keep native host metrics but report process coverage unavailable.
86
+
87
+ Local overhead observation (2026-10-22, Darwin arm64, Node 26.12.0, 10 logical CPUs, 16GiB RAM,
88
+ load about 6, not a quiet-runner benchmark): ten scans of 557 processes took 17.5-24.9ms each,
89
+ about 41.3KB captured, all complete. The Node collector consumed 25.7ms CPU total across ten
90
+ scans (2.6ms/sample, excludes helper CPU); a separate fixed `ps` invocation measured 0.02s wall,
91
+ 0.01s system CPU and 3.0MB peak RSS at `/usr/bin/time -lp` precision. Consumers should poll around
92
+ 1s, not every render frame. Linux cost is not inferred from macOS; native Linux CI runs the same
93
+ real-input gate and reports its snapshot duration/coverage.
94
+
95
+ Development: `npm run check --workspace @nredd/pi-resource-sampler` (single-worker tests, including
96
+ actual host samples and child cleanup). `test/runtime.test.ts` runs under both Vitest (Node) and
97
+ `bun test`: it starts a child whose argv[0], argv, environment, process title and (Linux) comm hold a
98
+ random secret, proves the 0.1.0 source exposes it, then requires no 12-character fragment in the
99
+ snapshot, plus three real CPU samples. `make check-resource-sampler-contract` builds and exercises
100
+ an isolated installed tarball, with no Pi or siblings. Both run on existing macOS/Linux CI jobs.
101
+ `dist` contains compiled ESM and declarations; the exact packed payload is contract-pinned. No install
102
+ scripts or runtime dependencies. New npm-name ownership bootstrap requires the owner's interactive
103
+ 2FA/staged publication and trusted-publisher setup; nothing is published automatically by this PR.
@@ -0,0 +1,40 @@
1
+ import { type ProcessCounter } from "./counters.js";
2
+ import type { ResourceSnapshot } from "./types.js";
3
+ /** Per-scan mutable budget, never shared between sampler instances. */
4
+ export declare class Budget {
5
+ readonly deadline: number;
6
+ readonly maxBytes: number;
7
+ readonly maxPids: number;
8
+ readonly signal: AbortSignal;
9
+ bytesRead: number;
10
+ enumerated: number;
11
+ skipped: number;
12
+ readonly reasons: Set<string>;
13
+ constructor(deadline: number, maxBytes: number, maxPids: number, signal: AbortSignal);
14
+ remaining(): number;
15
+ ready(): boolean;
16
+ /** Fixed-size proc read: no unbounded readFile allocation, and always await handle closure. */
17
+ read(path: string): Promise<string>;
18
+ /**
19
+ * Linux executable basename, or "" when the exe link is unreadable (other users' processes without
20
+ * ptrace read access, exited processes), over budget or possibly truncated. Errors carry no detail
21
+ * outward. Node and Bun cannot cap readlink below their fixed PATH_MAX buffer, so the length is only
22
+ * known after the syscall: a target beyond the remaining budget is charged the remainder and dropped,
23
+ * keeping `bytesRead <= maxBytes`. Node returns a full buffer silently truncated, so that is dropped too.
24
+ */
25
+ executable(path: string): Promise<string>;
26
+ }
27
+ /** Fixed executable/argv only. Bound capture, kill on cancellation/deadline, await close/reaping. */
28
+ export declare function command(executable: string, args: string[], budget: Budget, maxLines?: number): Promise<{
29
+ text: string;
30
+ complete: boolean;
31
+ }>;
32
+ /** Linux scan is sequential and closes the directory even on cancellation. */
33
+ export declare function linuxProcesses(budget: Budget, hz: number): Promise<ProcessCounter[]>;
34
+ /**
35
+ * Fixed metadata-only ps format. `ucomm` prints kinfo `p_comm`, never `comm`: `ps -c -o comm` shows the
36
+ * basename of argv[0], which any launcher picks and Node's `process.title` rewrites in place.
37
+ */
38
+ export declare function darwinProcesses(budget: Budget): Promise<ProcessCounter[]>;
39
+ /** PSI is optional and platform-specific; absence is not zero pressure. */
40
+ export declare function pressure(budget: Budget, platform: string): Promise<ResourceSnapshot["pressure"]>;
@@ -0,0 +1,231 @@
1
+ import { spawn } from "node:child_process";
2
+ import { open, opendir, readlink } from "node:fs/promises";
3
+ import { performance } from "node:perf_hooks";
4
+ import { darwinProcess, executableName, linuxRss, linuxStat, psi } from "./counters.js";
5
+ /** Linux PATH_MAX: Node (libuv `pathconf`) and Bun (`PathBuffer`) readlink buffer size on Linux. */
6
+ const LINK_BUFFER_BYTES = 4096;
7
+ /** Per-scan mutable budget, never shared between sampler instances. */
8
+ export class Budget {
9
+ deadline;
10
+ maxBytes;
11
+ maxPids;
12
+ signal;
13
+ bytesRead = 0;
14
+ enumerated = 0;
15
+ skipped = 0;
16
+ reasons = new Set();
17
+ constructor(deadline, maxBytes, maxPids, signal) {
18
+ this.deadline = deadline;
19
+ this.maxBytes = maxBytes;
20
+ this.maxPids = maxPids;
21
+ this.signal = signal;
22
+ }
23
+ remaining() {
24
+ this.signal.throwIfAborted();
25
+ return Math.max(0, this.deadline - performance.now());
26
+ }
27
+ ready() {
28
+ if (this.remaining() <= 0)
29
+ this.reasons.add("deadline");
30
+ if (this.bytesRead >= this.maxBytes)
31
+ this.reasons.add("byte-limit");
32
+ return this.remaining() > 0 && this.bytesRead < this.maxBytes;
33
+ }
34
+ /** Fixed-size proc read: no unbounded readFile allocation, and always await handle closure. */
35
+ async read(path) {
36
+ if (!this.ready())
37
+ throw new Error("budget exhausted");
38
+ const capacity = Math.min(8192, this.maxBytes - this.bytesRead);
39
+ const file = await open(path, "r");
40
+ try {
41
+ if (!this.ready())
42
+ throw new Error("budget exhausted while opening proc file");
43
+ const buffer = Buffer.alloc(capacity);
44
+ const { bytesRead } = await file.read(buffer, 0, capacity, 0);
45
+ this.bytesRead += bytesRead;
46
+ this.signal.throwIfAborted();
47
+ if (bytesRead === capacity) {
48
+ this.reasons.add("file-byte-limit");
49
+ throw new Error("proc file may be truncated");
50
+ }
51
+ return buffer.toString("utf8", 0, bytesRead);
52
+ }
53
+ finally {
54
+ await file.close();
55
+ }
56
+ }
57
+ /**
58
+ * Linux executable basename, or "" when the exe link is unreadable (other users' processes without
59
+ * ptrace read access, exited processes), over budget or possibly truncated. Errors carry no detail
60
+ * outward. Node and Bun cannot cap readlink below their fixed PATH_MAX buffer, so the length is only
61
+ * known after the syscall: a target beyond the remaining budget is charged the remainder and dropped,
62
+ * keeping `bytesRead <= maxBytes`. Node returns a full buffer silently truncated, so that is dropped too.
63
+ */
64
+ async executable(path) {
65
+ if (!this.ready())
66
+ return "";
67
+ let target;
68
+ try {
69
+ target = await readlink(path, { encoding: "buffer" });
70
+ }
71
+ catch {
72
+ this.signal.throwIfAborted();
73
+ return "";
74
+ }
75
+ this.signal.throwIfAborted();
76
+ const remaining = this.maxBytes - this.bytesRead;
77
+ if (target.length > remaining) {
78
+ this.bytesRead = this.maxBytes;
79
+ this.reasons.add("byte-limit");
80
+ return "";
81
+ }
82
+ this.bytesRead += target.length;
83
+ if (target.length >= LINK_BUFFER_BYTES) {
84
+ this.reasons.add("file-byte-limit");
85
+ return "";
86
+ }
87
+ return executableName(target.toString("utf8"));
88
+ }
89
+ }
90
+ /** Fixed executable/argv only. Bound capture, kill on cancellation/deadline, await close/reaping. */
91
+ export async function command(executable, args, budget, maxLines = Number.POSITIVE_INFINITY) {
92
+ if (!budget.ready())
93
+ return { text: "", complete: false };
94
+ return new Promise((resolve) => {
95
+ const child = spawn(executable, args, {
96
+ stdio: ["ignore", "pipe", "pipe"],
97
+ env: { LC_ALL: "C", PATH: "/usr/bin:/bin" },
98
+ });
99
+ const buffers = [];
100
+ let complete = true;
101
+ let lines = 0;
102
+ const stop = (reason) => {
103
+ complete = false;
104
+ budget.reasons.add(reason);
105
+ child.kill("SIGKILL");
106
+ };
107
+ const abort = () => stop("cancelled");
108
+ const timer = setTimeout(() => stop("deadline"), budget.remaining());
109
+ budget.signal.addEventListener("abort", abort, { once: true });
110
+ // Close the race between the ready check and listener registration.
111
+ if (budget.signal.aborted)
112
+ abort();
113
+ child.stdout.on("data", (data) => {
114
+ if (!complete)
115
+ return;
116
+ const remaining = budget.maxBytes - budget.bytesRead;
117
+ const kept = data.subarray(0, remaining);
118
+ budget.bytesRead += kept.length;
119
+ let end = kept.length;
120
+ for (let i = 0; i < kept.length; i++) {
121
+ if (kept[i] === 10 && ++lines > maxLines) {
122
+ end = i + 1;
123
+ stop("pid-limit");
124
+ break;
125
+ }
126
+ }
127
+ buffers.push(kept.subarray(0, end));
128
+ if (data.length >= remaining)
129
+ stop("byte-limit");
130
+ });
131
+ child.stderr.on("data", (data) => {
132
+ const remaining = budget.maxBytes - budget.bytesRead;
133
+ budget.bytesRead += Math.min(remaining, data.length);
134
+ if (data.length >= remaining)
135
+ stop("byte-limit");
136
+ });
137
+ child.on("error", () => {
138
+ complete = false;
139
+ budget.reasons.add("command-unavailable");
140
+ });
141
+ child.on("close", (code) => {
142
+ clearTimeout(timer);
143
+ budget.signal.removeEventListener("abort", abort);
144
+ if (code !== 0 && complete) {
145
+ complete = false;
146
+ budget.reasons.add("command-failed");
147
+ }
148
+ let text = Buffer.concat(buffers).toString("utf8");
149
+ if (!complete)
150
+ text = text.slice(0, text.lastIndexOf("\n") + 1);
151
+ resolve({ text, complete });
152
+ });
153
+ });
154
+ }
155
+ /** Linux scan is sequential and closes the directory even on cancellation. */
156
+ export async function linuxProcesses(budget, hz) {
157
+ const result = [];
158
+ const directory = await opendir("/proc");
159
+ for await (const entry of directory) {
160
+ if (!/^\d+$/.test(entry.name))
161
+ continue;
162
+ if (!budget.ready())
163
+ break;
164
+ if (budget.enumerated >= budget.maxPids) {
165
+ budget.reasons.add("pid-limit");
166
+ break;
167
+ }
168
+ budget.enumerated++;
169
+ try {
170
+ const path = `/proc/${entry.name}`;
171
+ const first = linuxStat(await budget.read(`${path}/stat`), hz, performance.now());
172
+ if (!first)
173
+ throw new Error("process incomplete");
174
+ const name = await budget.executable(`${path}/exe`);
175
+ const rssBytes = linuxRss(await budget.read(`${path}/status`));
176
+ const last = linuxStat(await budget.read(`${path}/stat`), hz, performance.now());
177
+ // The name and RSS reads sit between two stat reads, so PID reuse rejects both.
178
+ if (!last || first.pid !== Number(entry.name) || first.identity !== last.identity || rssBytes === null)
179
+ throw new Error("process changed or incomplete");
180
+ result.push({ ...last, name, rssBytes });
181
+ }
182
+ catch {
183
+ budget.signal.throwIfAborted();
184
+ budget.skipped++;
185
+ budget.reasons.add("process-unreadable-or-changed");
186
+ }
187
+ }
188
+ return result;
189
+ }
190
+ /**
191
+ * Fixed metadata-only ps format. `ucomm` prints kinfo `p_comm`, never `comm`: `ps -c -o comm` shows the
192
+ * basename of argv[0], which any launcher picks and Node's `process.title` rewrites in place.
193
+ */
194
+ export async function darwinProcesses(budget) {
195
+ const { text } = await command("/bin/ps", ["-A", "-o", "pid=,ppid=,lstart=,time=,rss=,ucomm="], budget, budget.maxPids);
196
+ const result = [];
197
+ for (const line of text.split("\n")) {
198
+ if (!line.trim())
199
+ continue;
200
+ if (budget.enumerated >= budget.maxPids) {
201
+ budget.reasons.add("pid-limit");
202
+ break;
203
+ }
204
+ budget.enumerated++;
205
+ const parsed = darwinProcess(line, performance.now());
206
+ if (parsed)
207
+ result.push(parsed);
208
+ else {
209
+ budget.skipped++;
210
+ budget.reasons.add("malformed-process-row");
211
+ }
212
+ }
213
+ return result;
214
+ }
215
+ /** PSI is optional and platform-specific; absence is not zero pressure. */
216
+ export async function pressure(budget, platform) {
217
+ if (platform !== "linux")
218
+ return { source: "unsupported", reason: "no comparable native PSI metric on this platform" };
219
+ try {
220
+ const value = psi(await budget.read("/proc/pressure/memory"));
221
+ if (value)
222
+ return { source: "linux-psi", memory: { state: "available", value } };
223
+ }
224
+ catch {
225
+ budget.signal.throwIfAborted();
226
+ }
227
+ return {
228
+ source: "linux-psi",
229
+ memory: { state: "unavailable", reason: "PSI absent, invalid or scan budget exhausted" },
230
+ };
231
+ }
@@ -0,0 +1,44 @@
1
+ import type { CpuInfo } from "node:os";
2
+ import type { Metric, PsiLine } from "./types.js";
3
+ /** Internal raw process counter with a per-read monotonic timestamp. */
4
+ export interface ProcessCounter {
5
+ pid: number;
6
+ parentPid: number;
7
+ name: string;
8
+ identity: string;
9
+ rssBytes: number;
10
+ cpuMs: number;
11
+ at: number;
12
+ }
13
+ /**
14
+ * Copy counters immediately. Bun's `os.cpus()` returns lazy placeholders that read native counters on
15
+ * first property access, so a stored but unread array would silently describe a later instant.
16
+ */
17
+ export declare function cpuSnapshot(list: readonly CpuInfo[]): CpuInfo[];
18
+ /** Compute an aggregate interval, validating each core and each counter before summing. */
19
+ export declare function hostCpu(previous: CpuInfo[] | undefined, current: CpuInfo[]): Metric<number>;
20
+ /** CPU milliseconds divided by monotonic wall milliseconds, not a lifetime/decaying ps average. */
21
+ export declare function processCpu(previous: ProcessCounter | undefined, current: ProcessCounter): Metric<number>;
22
+ /** Bound display names by code point and replace terminal controls; directories never leave the parser. */
23
+ export declare function safeName(value: string): string;
24
+ /** Basename of a Linux `/proc/PID/exe` target, without the kernel's unlinked-file marker. */
25
+ export declare function executableName(target: string): string;
26
+ /**
27
+ * Parse Linux stat, skipping the parenthesized comm safely (embedded spaces and ')' characters). comm is
28
+ * NOT a name source: processes rewrite it (`prctl(PR_SET_NAME)`, `/proc/self/comm`, Node's
29
+ * `process.title`). `name` stays ""; callers derive it from `/proc/PID/exe`.
30
+ */
31
+ export declare function linuxStat(text: string, hz: number, at: number): ProcessCounter | null;
32
+ /** RSS comes from status in KiB, avoiding an assumed host page size. */
33
+ export declare function linuxRss(text: string): number | null;
34
+ /**
35
+ * Parse `ps -o pid=,ppid=,lstart=,time=,rss=,ucomm=`. `ucomm` is the kernel's `p_comm`, written only at
36
+ * exec from the executable file name (at most 16 bytes); it is not argv[0] or the process title.
37
+ * Darwin lstart has only one-second identity precision; do not derive interval CPU from it.
38
+ */
39
+ export declare function darwinProcess(line: string, at: number): ProcessCounter | null;
40
+ /** Parse native PSI percentages and cumulative stall microseconds, rejecting malformed values. */
41
+ export declare function psi(text: string): {
42
+ some: PsiLine;
43
+ full: PsiLine | null;
44
+ } | null;
@@ -0,0 +1,141 @@
1
+ import { basename } from "node:path";
2
+ /**
3
+ * Copy counters immediately. Bun's `os.cpus()` returns lazy placeholders that read native counters on
4
+ * first property access, so a stored but unread array would silently describe a later instant.
5
+ */
6
+ export function cpuSnapshot(list) {
7
+ return list.map(({ model, speed, times }) => ({
8
+ model,
9
+ speed,
10
+ times: { user: times.user, nice: times.nice, sys: times.sys, idle: times.idle, irq: times.irq },
11
+ }));
12
+ }
13
+ /** Compute an aggregate interval, validating each core and each counter before summing. */
14
+ export function hostCpu(previous, current) {
15
+ if (!previous || previous.length !== current.length || current.length === 0) {
16
+ return { state: "warming", reason: "initial sample or CPU topology changed" };
17
+ }
18
+ let total = 0;
19
+ let idle = 0;
20
+ for (let i = 0; i < current.length; i++) {
21
+ const before = previous[i];
22
+ const after = current[i];
23
+ if (!before || !after || before.model !== after.model)
24
+ return { state: "warming", reason: "CPU topology changed" };
25
+ for (const key of ["user", "nice", "sys", "idle", "irq"]) {
26
+ const delta = after.times[key] - before.times[key];
27
+ if (!Number.isFinite(delta) || before.times[key] < 0 || after.times[key] < 0 || delta < 0)
28
+ return { state: "warming", reason: "CPU counter reset or invalid" };
29
+ total += delta;
30
+ if (key === "idle")
31
+ idle += delta;
32
+ }
33
+ }
34
+ return total > 0
35
+ ? { state: "available", value: ((total - idle) / total) * 100 }
36
+ : { state: "warming", reason: "CPU counters have not advanced" };
37
+ }
38
+ /** CPU milliseconds divided by monotonic wall milliseconds, not a lifetime/decaying ps average. */
39
+ export function processCpu(previous, current) {
40
+ if (!previous || previous.identity !== current.identity)
41
+ return { state: "warming", reason: "initial sample or PID reused" };
42
+ const elapsed = current.at - previous.at;
43
+ const cpu = current.cpuMs - previous.cpuMs;
44
+ if (!Number.isFinite(elapsed) ||
45
+ elapsed <= 0 ||
46
+ !Number.isFinite(cpu) ||
47
+ previous.cpuMs < 0 ||
48
+ current.cpuMs < 0 ||
49
+ cpu < 0) {
50
+ return { state: "warming", reason: "invalid interval or counter reset" };
51
+ }
52
+ return { state: "available", value: (cpu / elapsed) * 100 };
53
+ }
54
+ /** Bound display names by code point and replace terminal controls; directories never leave the parser. */
55
+ export function safeName(value) {
56
+ return Array.from(basename(value).replace(/[\p{C}]/gu, "?"))
57
+ .slice(0, 128)
58
+ .join("");
59
+ }
60
+ /** Basename of a Linux `/proc/PID/exe` target, without the kernel's unlinked-file marker. */
61
+ export function executableName(target) {
62
+ return safeName(target.endsWith(" (deleted)") ? target.slice(0, -" (deleted)".length) : target);
63
+ }
64
+ /**
65
+ * Parse Linux stat, skipping the parenthesized comm safely (embedded spaces and ')' characters). comm is
66
+ * NOT a name source: processes rewrite it (`prctl(PR_SET_NAME)`, `/proc/self/comm`, Node's
67
+ * `process.title`). `name` stays ""; callers derive it from `/proc/PID/exe`.
68
+ */
69
+ export function linuxStat(text, hz, at) {
70
+ const start = text.indexOf("(");
71
+ const end = text.lastIndexOf(")");
72
+ if (start < 1 || end <= start || !Number.isFinite(hz) || hz <= 0)
73
+ return null;
74
+ const pid = Number(text.slice(0, start).trim());
75
+ const fields = text
76
+ .slice(end + 1)
77
+ .trim()
78
+ .split(/\s+/);
79
+ const parentPid = Number(fields[1]);
80
+ const user = Number(fields[11]);
81
+ const system = Number(fields[12]);
82
+ const identity = fields[19];
83
+ if (!identity ||
84
+ !/^\d+$/.test(identity) ||
85
+ ![pid, parentPid, user, system].every((n) => Number.isSafeInteger(n) && n >= 0) ||
86
+ pid === 0)
87
+ return null;
88
+ return {
89
+ pid,
90
+ parentPid,
91
+ name: "",
92
+ identity: `${pid}:${identity}`,
93
+ rssBytes: 0,
94
+ cpuMs: ((user + system) / hz) * 1000,
95
+ at,
96
+ };
97
+ }
98
+ /** RSS comes from status in KiB, avoiding an assumed host page size. */
99
+ export function linuxRss(text) {
100
+ const match = /^VmRSS:\s+(\d+)\s+kB$/m.exec(text);
101
+ if (!match?.[1])
102
+ return null;
103
+ const bytes = Number(match[1]) * 1024;
104
+ return Number.isSafeInteger(bytes) ? bytes : null;
105
+ }
106
+ /**
107
+ * Parse `ps -o pid=,ppid=,lstart=,time=,rss=,ucomm=`. `ucomm` is the kernel's `p_comm`, written only at
108
+ * exec from the executable file name (at most 16 bytes); it is not argv[0] or the process title.
109
+ * Darwin lstart has only one-second identity precision; do not derive interval CPU from it.
110
+ */
111
+ export function darwinProcess(line, at) {
112
+ const match = /^\s*(\d+)\s+(\d+)\s+(\w{3}\s+\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(\S+)\s+(\d+)\s+(.*?)\s*$/.exec(line);
113
+ if (!match?.[1] || !match[2] || !match[3] || !match[5] || match[6] === undefined)
114
+ return null;
115
+ const pid = Number(match[1]);
116
+ const parentPid = Number(match[2]);
117
+ const rssBytes = Number(match[5]) * 1024;
118
+ if (![pid, parentPid, rssBytes].every(Number.isSafeInteger))
119
+ return null;
120
+ return { pid, parentPid, rssBytes, identity: `${pid}:${match[3]}`, name: safeName(match[6]), cpuMs: 0, at };
121
+ }
122
+ /** Parse native PSI percentages and cumulative stall microseconds, rejecting malformed values. */
123
+ export function psi(text) {
124
+ const rows = new Map();
125
+ for (const line of text.trim().split("\n")) {
126
+ const match = /^(some|full) avg10=(\d+(?:\.\d+)?) avg60=(\d+(?:\.\d+)?) avg300=(\d+(?:\.\d+)?) total=(\d+)$/.exec(line);
127
+ if (!match?.[1])
128
+ return null;
129
+ const [avg10, avg60, avg300, total] = match.slice(2).map(Number);
130
+ if (avg10 === undefined ||
131
+ avg60 === undefined ||
132
+ avg300 === undefined ||
133
+ total === undefined ||
134
+ ![avg10, avg60, avg300].every((n) => Number.isFinite(n) && n >= 0 && n <= 100) ||
135
+ !Number.isSafeInteger(total))
136
+ return null;
137
+ rows.set(match[1], { avg10, avg60, avg300, total });
138
+ }
139
+ const some = rows.get("some");
140
+ return some ? { some, full: rows.get("full") ?? null } : null;
141
+ }
@@ -0,0 +1,4 @@
1
+ import type { ResourceSampler, SamplerOptions } from "./types.js";
2
+ export type { Metric, ProcessSample, PsiLine, ResourceSampler, ResourceSnapshot, SamplerOptions } from "./types.js";
3
+ /** Create an isolated, passive sampler. No timers or child processes exist until sample(). */
4
+ export declare function createResourceSampler(options?: SamplerOptions): ResourceSampler;
package/dist/index.js ADDED
@@ -0,0 +1,132 @@
1
+ import { cpus, freemem, loadavg, platform, totalmem } from "node:os";
2
+ import { performance } from "node:perf_hooks";
3
+ import { Budget, command, darwinProcesses, linuxProcesses, pressure } from "./collect.js";
4
+ import { cpuSnapshot, hostCpu, processCpu } from "./counters.js";
5
+ /** Validate bounded integers before collecting any host information. */
6
+ function bounded(value, fallback, min, max) {
7
+ const result = value ?? fallback;
8
+ if (!Number.isInteger(result) || result < min || result > max)
9
+ throw new RangeError(`Sampler bound must be an integer from ${min} to ${max}`);
10
+ return result;
11
+ }
12
+ /** Create an isolated, passive sampler. No timers or child processes exist until sample(). */
13
+ export function createResourceSampler(options = {}) {
14
+ const duration = bounded(options.maxDurationMs, 250, 10, 5000);
15
+ const maxPids = bounded(options.maxPids, 2048, 1, 16384);
16
+ const maxBytes = bounded(options.maxBytes, 2_097_152, 4096, 16_777_216);
17
+ const host = platform();
18
+ let previousCpus;
19
+ let previousTime;
20
+ let previousProcesses = new Map();
21
+ let hz;
22
+ let active;
23
+ let controller;
24
+ let disposed = false;
25
+ async function collect(signal) {
26
+ const start = performance.now();
27
+ const wallTimeMs = Date.now();
28
+ const budget = new Budget(start + duration, maxBytes, maxPids, signal);
29
+ signal.throwIfAborted();
30
+ const currentCpus = cpuSnapshot(cpus());
31
+ const cpu = previousTime !== undefined && start <= previousTime
32
+ ? { state: "warming", reason: "invalid monotonic interval" }
33
+ : hostCpu(previousCpus, currentCpus);
34
+ const nativePressure = await pressure(budget, host);
35
+ let counters = [];
36
+ try {
37
+ if (host === "linux") {
38
+ if (hz === undefined) {
39
+ const result = await command("/usr/bin/getconf", ["CLK_TCK"], budget);
40
+ const value = Number(result.text.trim());
41
+ if (result.complete && Number.isSafeInteger(value) && value > 0)
42
+ hz = value;
43
+ }
44
+ if (hz !== undefined && budget.ready())
45
+ counters = await linuxProcesses(budget, hz);
46
+ else
47
+ budget.reasons.add("clock-ticks-unavailable");
48
+ }
49
+ else if (host === "darwin")
50
+ counters = await darwinProcesses(budget);
51
+ else
52
+ budget.reasons.add("unsupported-platform");
53
+ }
54
+ catch {
55
+ signal.throwIfAborted();
56
+ budget.reasons.add("process-collection-unavailable");
57
+ }
58
+ signal.throwIfAborted();
59
+ const processes = counters.map((counter) => ({
60
+ pid: counter.pid,
61
+ parentPid: counter.parentPid,
62
+ name: counter.name,
63
+ identity: counter.identity,
64
+ rssBytes: counter.rssBytes,
65
+ cpu: host === "darwin"
66
+ ? {
67
+ state: "unavailable",
68
+ reason: "ps start identity precision is one second; PID reuse cannot be excluded",
69
+ }
70
+ : processCpu(previousProcesses.get(counter.pid), counter),
71
+ }));
72
+ // Only successfully completed scans advance baselines. Missing processes lose their baselines.
73
+ previousCpus = currentCpus;
74
+ previousTime = start;
75
+ previousProcesses = new Map(counters.map((counter) => [counter.pid, counter]));
76
+ if (performance.now() > budget.deadline)
77
+ budget.reasons.add("deadline");
78
+ return {
79
+ version: 1,
80
+ platform: host,
81
+ wallTimeMs,
82
+ monotonicTimeMs: start,
83
+ durationMs: performance.now() - start,
84
+ cpu,
85
+ logicalCpus: currentCpus.length,
86
+ loadAverage: host === "win32" ? [] : loadavg(),
87
+ memory: { totalBytes: totalmem(), freeBytes: freemem() },
88
+ processes,
89
+ coverage: {
90
+ complete: budget.reasons.size === 0,
91
+ enumerated: budget.enumerated,
92
+ sampled: processes.length,
93
+ skipped: budget.skipped,
94
+ bytesRead: budget.bytesRead,
95
+ reasons: [...budget.reasons],
96
+ },
97
+ pressure: nativePressure,
98
+ };
99
+ }
100
+ return {
101
+ sample(signal) {
102
+ if (disposed)
103
+ return Promise.reject(new Error("Resource sampler is disposed"));
104
+ if (active)
105
+ return Promise.reject(new Error("Resource sampler already has an active sample"));
106
+ controller = new AbortController();
107
+ const local = controller;
108
+ const abort = () => local.abort(signal?.reason);
109
+ if (signal?.aborted)
110
+ abort();
111
+ signal?.addEventListener("abort", abort, { once: true });
112
+ active = collect(local.signal).finally(() => {
113
+ signal?.removeEventListener("abort", abort);
114
+ active = undefined;
115
+ controller = undefined;
116
+ });
117
+ return active;
118
+ },
119
+ async dispose() {
120
+ disposed = true;
121
+ controller?.abort();
122
+ try {
123
+ await active;
124
+ }
125
+ catch {
126
+ /* The sample caller receives the cancellation. Disposal waits for cleanup. */
127
+ }
128
+ previousCpus = undefined;
129
+ previousProcesses.clear();
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,84 @@
1
+ /** An absent measurement is never encoded as a zero. */
2
+ export type Metric<T> = {
3
+ state: "available";
4
+ value: T;
5
+ } | {
6
+ state: "warming" | "unavailable";
7
+ reason: string;
8
+ };
9
+ /** One process observed without command arguments, environment or agent attribution. */
10
+ export interface ProcessSample {
11
+ pid: number;
12
+ parentPid: number;
13
+ /**
14
+ * Executable basename from kernel metadata, never argv, environment, process title or Linux comm:
15
+ * Darwin `p_comm` (at most 16 bytes), Linux `/proc/PID/exe` basename. "" means unavailable,
16
+ * e.g. another user's Linux process. Untrusted display text: whoever names the file chooses it, and
17
+ * a Linux process can exec a `memfd_create` file it named itself.
18
+ */
19
+ name: string;
20
+ identity: string;
21
+ rssBytes: number;
22
+ /** Interval utilization: 100 is one logical core, values may exceed 100. */
23
+ cpu: Metric<number>;
24
+ }
25
+ /** Linux pressure stall information, not memory utilization or a universal pressure score. */
26
+ export interface PsiLine {
27
+ avg10: number;
28
+ avg60: number;
29
+ avg300: number;
30
+ /** Cumulative microseconds stalled. */
31
+ total: number;
32
+ }
33
+ /** Snapshot captured over a bounded, non-atomic scan. */
34
+ export interface ResourceSnapshot {
35
+ version: 1;
36
+ platform: string;
37
+ wallTimeMs: number;
38
+ monotonicTimeMs: number;
39
+ durationMs: number;
40
+ /** Aggregate host interval utilization, 0..100. */
41
+ cpu: Metric<number>;
42
+ logicalCpus: number;
43
+ loadAverage: number[];
44
+ memory: {
45
+ totalBytes: number;
46
+ freeBytes: number;
47
+ };
48
+ processes: ProcessSample[];
49
+ coverage: {
50
+ complete: boolean;
51
+ /** Entries encountered, not a claimed host-wide process count when incomplete. */
52
+ enumerated: number;
53
+ sampled: number;
54
+ skipped: number;
55
+ bytesRead: number;
56
+ reasons: string[];
57
+ };
58
+ pressure: {
59
+ source: "linux-psi";
60
+ memory: Metric<{
61
+ some: PsiLine;
62
+ full: PsiLine | null;
63
+ }>;
64
+ } | {
65
+ source: "unsupported";
66
+ reason: string;
67
+ };
68
+ }
69
+ /** Bounds per sample; filesystem/kernel scheduling can delay completion past the deadline. */
70
+ export interface SamplerOptions {
71
+ /** Default 250ms, range 10..5000. No new work after this monotonic deadline. */
72
+ maxDurationMs?: number;
73
+ /** Default 2048, range 1..16384. Sequential Linux reads (concurrency one). */
74
+ maxPids?: number;
75
+ /** Default 2MiB, range 4096..16MiB, includes helper stdout/stderr and proc contents. */
76
+ maxBytes?: number;
77
+ }
78
+ /** Caller owns cadence. No background polling, caching across instances or Pi dependency. */
79
+ export interface ResourceSampler {
80
+ /** Rejects concurrent calls, disposal and caller cancellation. */
81
+ sample(signal?: AbortSignal): Promise<ResourceSnapshot>;
82
+ /** Aborts and awaits any collection and child reaping. Idempotent. */
83
+ dispose(): Promise<void>;
84
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "name": "@nredd/pi-resource-sampler",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Bounded, privacy-safe host resource interval sampling without Pi dependencies.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "nredd",
8
+ "publishConfig": { "access": "public" },
9
+ "engines": { "node": ">=22.14.0" },
10
+ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
11
+ "types": "./dist/index.d.ts",
12
+ "files": ["dist", "LICENSE", "README.md"],
13
+ "repository": { "type": "git", "url": "git+https://github.com/nredd/pi-harness.git", "directory": "packages/resource-sampler" },
14
+ "scripts": {
15
+ "build": "tsc -p tsconfig.build.json",
16
+ "lint": "biome check src test",
17
+ "typecheck": "tsc --noEmit",
18
+ "test": "vitest run --maxWorkers=1",
19
+ "test:bun": "bun test test/runtime.test.ts",
20
+ "check": "npm run lint && npm run typecheck && npm run build && npm test && npm run test:bun"
21
+ },
22
+ "devDependencies": { "@biomejs/biome": "2.5.15", "@types/node": "26.6.5", "typescript": "7.0.2", "vitest": "5.0.3" }
23
+ }