@nredd/pi-resource-sampler 0.0.0-bootstrap.0 → 0.1.0

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 +1,74 @@
1
- Namespace bootstrap only. This package contains no sampler implementation and must not be used by consumers. The first functional release will be published by nredd/pi-harness's trusted publish.yml workflow.
1
+ # Resource sampler
2
+
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. Initial implementation only; no consumer migration or npm publication in this PR.
6
+
7
+ ```ts
8
+ import { createResourceSampler } from '@nredd/pi-resource-sampler';
9
+ const sampler = createResourceSampler();
10
+ try {
11
+ const snapshot = await sampler.sample();
12
+ // Caller schedules subsequent samples and renders typed metrics.
13
+ } finally {
14
+ await sampler.dispose();
15
+ }
16
+ ```
17
+
18
+ `sample(signal?)` returns `ResourceSnapshot` v1. Concurrent calls reject. Cancellation rejects and
19
+ awaits cleanup; `dispose()` aborts and awaits any active sample, is idempotent, and forbids future
20
+ samples. Importing/constructing the library does not collect data. Each instance owns its baseline.
21
+
22
+ Semantics:
23
+
24
+ - `cpu`: aggregate host CPU counter delta, 0..100. Core count/model changes, individual counter
25
+ rollback, initial samples, invalid intervals and counters that have not advanced are `warming`,
26
+ never fabricated zero. Uses Node's native `os.cpus()` counters, not load average.
27
+ - `processes[].cpu`: Linux user+system counter delta divided by monotonic elapsed milliseconds.
28
+ 100 means one logical core, multi-threaded processes can exceed 100. Linux `CLK_TCK` is read
29
+ once with fixed `/usr/bin/getconf CLK_TCK`; no assumed tick rate or RSS page size.
30
+ - Linux identity includes the exact `/proc/PID/stat` start tick string. Read stat/status/stat and
31
+ reject identity changes during RSS collection. Missing or inaccessible processes are skipped and
32
+ invalidate their old baselines. New/reused PIDs and counter resets warm rather than showing zero.
33
+ - Darwin uses only `/bin/ps -A -c -o pid=,ppid=,lstart=,time=,rss=,comm=` with `LC_ALL=C`.
34
+ Actual macOS observation: `time` prints hundredths (e.g. `185:32.40`), but `lstart` prints only
35
+ seconds (`Sat Oct 3 01:49:05 2026`). That cannot rule out PID reuse within one second, so Darwin
36
+ process interval CPU is explicitly `unavailable`. No decaying `%cpu` is relabelled as an interval.
37
+ RSS/name/parent PID remain useful. No native helper until this limitation justifies one separately.
38
+ - `wallTimeMs` labels the start; `monotonicTimeMs` controls intervals. Per-process reads have their own
39
+ monotonic timestamps internally; the scan is not atomic. `durationMs` includes collection.
40
+ - `memory.freeBytes` means free, NOT available or pressure. `loadAverage` is load, NOT percent.
41
+ Linux memory PSI preserves native `some`/optional `full`, avg10/60/300 percentages and cumulative
42
+ stall microseconds. Missing/invalid PSI is unavailable. Other platforms explicitly have no PSI.
43
+ - Names are bounded basenames with control characters removed; no arguments, environments,
44
+ user names, executable paths or agent attribution are collected. Process names remain untrusted
45
+ display strings; consumers must not execute or interpret them.
46
+
47
+ Bounds:
48
+
49
+ - Default scan budget 250ms, 2048 PIDs, 2MiB captured bytes; options allow at most 5s, 16384 PIDs,
50
+ 16MiB. Linux proc files read sequentially (concurrency one), at most 8KiB each. Directory iteration
51
+ stops at the budget; Darwin output stops at the PID/output budget and reaps the fixed helper.
52
+ - `coverage.complete`, `enumerated`, `sampled`, `skipped`, `bytesRead` and reasons describe partial
53
+ coverage explicitly. `enumerated` is encountered rows, not total processes when partial. Truncation
54
+ is not a host-wide top-process ranking. OS enumeration order may favor the same subset each time.
55
+ - Deadline is a monotonic admission/child-kill bound, not a real-time guarantee: event-loop scheduling,
56
+ native `os.cpus()` and in-flight filesystem calls cannot be preempted. Cleanup is always awaited.
57
+ Helpers spawn no descendants, use no shell, stdin ignored, fixed environment, bounded stdout/stderr,
58
+ immediate SIGKILL on abort/deadline/cap, and resolve only on close. No persistent children or workers.
59
+ - Unsupported platforms keep native host metrics but report process coverage unavailable.
60
+
61
+ Local overhead observation (2026-10-22, Darwin arm64, Node 26.12.0, 10 logical CPUs, 16GiB RAM,
62
+ load about 6, not a quiet-runner benchmark): ten scans of 557 processes took 17.5-24.9ms each,
63
+ about 41.3KB captured, all complete. The Node collector consumed 25.7ms CPU total across ten
64
+ scans (2.6ms/sample, excludes helper CPU); a separate fixed `ps` invocation measured 0.02s wall,
65
+ 0.01s system CPU and 3.0MB peak RSS at `/usr/bin/time -lp` precision. Consumers should poll around
66
+ 1s, not every render frame. Linux cost is not inferred from macOS; native Linux CI runs the same
67
+ real-input gate and reports its snapshot duration/coverage.
68
+
69
+ Development: `npm run check --workspace @nredd/pi-resource-sampler` (single-worker tests, including
70
+ actual host samples and child cleanup); `make check-resource-sampler-contract` builds and exercises
71
+ an isolated installed tarball, with no Pi or siblings. Both run on existing macOS/Linux CI jobs.
72
+ `dist` contains compiled ESM and declarations; the exact packed payload is contract-pinned. No install
73
+ scripts or runtime dependencies. New npm-name ownership bootstrap requires the owner's interactive
74
+ 2FA/staged publication and trusted-publisher setup; nothing is published automatically by this PR.
@@ -0,0 +1,29 @@
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
+ /** Fixed executable/argv only. Bound capture, kill on cancellation/deadline, await close/reaping. */
20
+ export declare function command(executable: string, args: string[], budget: Budget, maxLines?: number): Promise<{
21
+ text: string;
22
+ complete: boolean;
23
+ }>;
24
+ /** Linux scan is sequential and closes the directory even on cancellation. */
25
+ export declare function linuxProcesses(budget: Budget, hz: number): Promise<ProcessCounter[]>;
26
+ /** Fixed metadata-only ps format; -c suppresses executable paths and arguments on Darwin. */
27
+ export declare function darwinProcesses(budget: Budget): Promise<ProcessCounter[]>;
28
+ /** PSI is optional and platform-specific; absence is not zero pressure. */
29
+ export declare function pressure(budget: Budget, platform: string): Promise<ResourceSnapshot["pressure"]>;
@@ -0,0 +1,190 @@
1
+ import { spawn } from "node:child_process";
2
+ import { open, opendir } from "node:fs/promises";
3
+ import { performance } from "node:perf_hooks";
4
+ import { darwinProcess, linuxRss, linuxStat, psi } from "./counters.js";
5
+ /** Per-scan mutable budget, never shared between sampler instances. */
6
+ export class Budget {
7
+ deadline;
8
+ maxBytes;
9
+ maxPids;
10
+ signal;
11
+ bytesRead = 0;
12
+ enumerated = 0;
13
+ skipped = 0;
14
+ reasons = new Set();
15
+ constructor(deadline, maxBytes, maxPids, signal) {
16
+ this.deadline = deadline;
17
+ this.maxBytes = maxBytes;
18
+ this.maxPids = maxPids;
19
+ this.signal = signal;
20
+ }
21
+ remaining() {
22
+ this.signal.throwIfAborted();
23
+ return Math.max(0, this.deadline - performance.now());
24
+ }
25
+ ready() {
26
+ if (this.remaining() <= 0)
27
+ this.reasons.add("deadline");
28
+ if (this.bytesRead >= this.maxBytes)
29
+ this.reasons.add("byte-limit");
30
+ return this.remaining() > 0 && this.bytesRead < this.maxBytes;
31
+ }
32
+ /** Fixed-size proc read: no unbounded readFile allocation, and always await handle closure. */
33
+ async read(path) {
34
+ if (!this.ready())
35
+ throw new Error("budget exhausted");
36
+ const capacity = Math.min(8192, this.maxBytes - this.bytesRead);
37
+ const file = await open(path, "r");
38
+ try {
39
+ if (!this.ready())
40
+ throw new Error("budget exhausted while opening proc file");
41
+ const buffer = Buffer.alloc(capacity);
42
+ const { bytesRead } = await file.read(buffer, 0, capacity, 0);
43
+ this.bytesRead += bytesRead;
44
+ this.signal.throwIfAborted();
45
+ if (bytesRead === capacity) {
46
+ this.reasons.add("file-byte-limit");
47
+ throw new Error("proc file may be truncated");
48
+ }
49
+ return buffer.toString("utf8", 0, bytesRead);
50
+ }
51
+ finally {
52
+ await file.close();
53
+ }
54
+ }
55
+ }
56
+ /** Fixed executable/argv only. Bound capture, kill on cancellation/deadline, await close/reaping. */
57
+ export async function command(executable, args, budget, maxLines = Number.POSITIVE_INFINITY) {
58
+ if (!budget.ready())
59
+ return { text: "", complete: false };
60
+ return new Promise((resolve) => {
61
+ const child = spawn(executable, args, {
62
+ stdio: ["ignore", "pipe", "pipe"],
63
+ env: { LC_ALL: "C", PATH: "/usr/bin:/bin" },
64
+ });
65
+ const buffers = [];
66
+ let complete = true;
67
+ let lines = 0;
68
+ const stop = (reason) => {
69
+ complete = false;
70
+ budget.reasons.add(reason);
71
+ child.kill("SIGKILL");
72
+ };
73
+ const abort = () => stop("cancelled");
74
+ const timer = setTimeout(() => stop("deadline"), budget.remaining());
75
+ budget.signal.addEventListener("abort", abort, { once: true });
76
+ // Close the race between the ready check and listener registration.
77
+ if (budget.signal.aborted)
78
+ abort();
79
+ child.stdout.on("data", (data) => {
80
+ if (!complete)
81
+ return;
82
+ const remaining = budget.maxBytes - budget.bytesRead;
83
+ const kept = data.subarray(0, remaining);
84
+ budget.bytesRead += kept.length;
85
+ let end = kept.length;
86
+ for (let i = 0; i < kept.length; i++) {
87
+ if (kept[i] === 10 && ++lines > maxLines) {
88
+ end = i + 1;
89
+ stop("pid-limit");
90
+ break;
91
+ }
92
+ }
93
+ buffers.push(kept.subarray(0, end));
94
+ if (data.length >= remaining)
95
+ stop("byte-limit");
96
+ });
97
+ child.stderr.on("data", (data) => {
98
+ const remaining = budget.maxBytes - budget.bytesRead;
99
+ budget.bytesRead += Math.min(remaining, data.length);
100
+ if (data.length >= remaining)
101
+ stop("byte-limit");
102
+ });
103
+ child.on("error", () => {
104
+ complete = false;
105
+ budget.reasons.add("command-unavailable");
106
+ });
107
+ child.on("close", (code) => {
108
+ clearTimeout(timer);
109
+ budget.signal.removeEventListener("abort", abort);
110
+ if (code !== 0 && complete) {
111
+ complete = false;
112
+ budget.reasons.add("command-failed");
113
+ }
114
+ let text = Buffer.concat(buffers).toString("utf8");
115
+ if (!complete)
116
+ text = text.slice(0, text.lastIndexOf("\n") + 1);
117
+ resolve({ text, complete });
118
+ });
119
+ });
120
+ }
121
+ /** Linux scan is sequential and closes the directory even on cancellation. */
122
+ export async function linuxProcesses(budget, hz) {
123
+ const result = [];
124
+ const directory = await opendir("/proc");
125
+ for await (const entry of directory) {
126
+ if (!/^\d+$/.test(entry.name))
127
+ continue;
128
+ if (!budget.ready())
129
+ break;
130
+ if (budget.enumerated >= budget.maxPids) {
131
+ budget.reasons.add("pid-limit");
132
+ break;
133
+ }
134
+ budget.enumerated++;
135
+ try {
136
+ const path = `/proc/${entry.name}`;
137
+ const first = linuxStat(await budget.read(`${path}/stat`), hz, performance.now());
138
+ const rssBytes = linuxRss(await budget.read(`${path}/status`));
139
+ const last = linuxStat(await budget.read(`${path}/stat`), hz, performance.now());
140
+ if (!first || !last || first.pid !== Number(entry.name) || first.identity !== last.identity || rssBytes === null)
141
+ throw new Error("process changed or incomplete");
142
+ result.push({ ...last, rssBytes });
143
+ }
144
+ catch {
145
+ budget.signal.throwIfAborted();
146
+ budget.skipped++;
147
+ budget.reasons.add("process-unreadable-or-changed");
148
+ }
149
+ }
150
+ return result;
151
+ }
152
+ /** Fixed metadata-only ps format; -c suppresses executable paths and arguments on Darwin. */
153
+ export async function darwinProcesses(budget) {
154
+ const { text } = await command("/bin/ps", ["-A", "-c", "-o", "pid=,ppid=,lstart=,time=,rss=,comm="], budget, budget.maxPids);
155
+ const result = [];
156
+ for (const line of text.split("\n")) {
157
+ if (!line.trim())
158
+ continue;
159
+ if (budget.enumerated >= budget.maxPids) {
160
+ budget.reasons.add("pid-limit");
161
+ break;
162
+ }
163
+ budget.enumerated++;
164
+ const parsed = darwinProcess(line, performance.now());
165
+ if (parsed)
166
+ result.push(parsed);
167
+ else {
168
+ budget.skipped++;
169
+ budget.reasons.add("malformed-process-row");
170
+ }
171
+ }
172
+ return result;
173
+ }
174
+ /** PSI is optional and platform-specific; absence is not zero pressure. */
175
+ export async function pressure(budget, platform) {
176
+ if (platform !== "linux")
177
+ return { source: "unsupported", reason: "no comparable native PSI metric on this platform" };
178
+ try {
179
+ const value = psi(await budget.read("/proc/pressure/memory"));
180
+ if (value)
181
+ return { source: "linux-psi", memory: { state: "available", value } };
182
+ }
183
+ catch {
184
+ budget.signal.throwIfAborted();
185
+ }
186
+ return {
187
+ source: "linux-psi",
188
+ memory: { state: "unavailable", reason: "PSI absent, invalid or scan budget exhausted" },
189
+ };
190
+ }
@@ -0,0 +1,29 @@
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
+ /** Compute an aggregate interval, validating each core and each counter before summing. */
14
+ export declare function hostCpu(previous: CpuInfo[] | undefined, current: CpuInfo[]): Metric<number>;
15
+ /** CPU milliseconds divided by monotonic wall milliseconds, not a lifetime/decaying ps average. */
16
+ export declare function processCpu(previous: ProcessCounter | undefined, current: ProcessCounter): Metric<number>;
17
+ /** Bound display names and strip terminal controls; paths/arguments never leave the parser. */
18
+ export declare function safeName(value: string): string;
19
+ /** Parse Linux stat's parenthesized comm safely, including embedded spaces and ')' characters. */
20
+ export declare function linuxStat(text: string, hz: number, at: number): ProcessCounter | null;
21
+ /** RSS comes from status in KiB, avoiding an assumed host page size. */
22
+ export declare function linuxRss(text: string): number | null;
23
+ /** Darwin lstart has only one-second identity precision; do not derive interval CPU from it. */
24
+ export declare function darwinProcess(line: string, at: number): ProcessCounter | null;
25
+ /** Parse native PSI percentages and cumulative stall microseconds, rejecting malformed values. */
26
+ export declare function psi(text: string): {
27
+ some: PsiLine;
28
+ full: PsiLine | null;
29
+ } | null;
@@ -0,0 +1,118 @@
1
+ import { basename } from "node:path";
2
+ /** Compute an aggregate interval, validating each core and each counter before summing. */
3
+ export function hostCpu(previous, current) {
4
+ if (!previous || previous.length !== current.length || current.length === 0) {
5
+ return { state: "warming", reason: "initial sample or CPU topology changed" };
6
+ }
7
+ let total = 0;
8
+ let idle = 0;
9
+ for (let i = 0; i < current.length; i++) {
10
+ const before = previous[i];
11
+ const after = current[i];
12
+ if (!before || !after || before.model !== after.model)
13
+ return { state: "warming", reason: "CPU topology changed" };
14
+ for (const key of ["user", "nice", "sys", "idle", "irq"]) {
15
+ const delta = after.times[key] - before.times[key];
16
+ if (!Number.isFinite(delta) || before.times[key] < 0 || after.times[key] < 0 || delta < 0)
17
+ return { state: "warming", reason: "CPU counter reset or invalid" };
18
+ total += delta;
19
+ if (key === "idle")
20
+ idle += delta;
21
+ }
22
+ }
23
+ return total > 0
24
+ ? { state: "available", value: ((total - idle) / total) * 100 }
25
+ : { state: "warming", reason: "CPU counters have not advanced" };
26
+ }
27
+ /** CPU milliseconds divided by monotonic wall milliseconds, not a lifetime/decaying ps average. */
28
+ export function processCpu(previous, current) {
29
+ if (!previous || previous.identity !== current.identity)
30
+ return { state: "warming", reason: "initial sample or PID reused" };
31
+ const elapsed = current.at - previous.at;
32
+ const cpu = current.cpuMs - previous.cpuMs;
33
+ if (!Number.isFinite(elapsed) ||
34
+ elapsed <= 0 ||
35
+ !Number.isFinite(cpu) ||
36
+ previous.cpuMs < 0 ||
37
+ current.cpuMs < 0 ||
38
+ cpu < 0) {
39
+ return { state: "warming", reason: "invalid interval or counter reset" };
40
+ }
41
+ return { state: "available", value: (cpu / elapsed) * 100 };
42
+ }
43
+ /** Bound display names and strip terminal controls; paths/arguments never leave the parser. */
44
+ export function safeName(value) {
45
+ return basename(value)
46
+ .replace(/[\p{C}]/gu, "?")
47
+ .slice(0, 128);
48
+ }
49
+ /** Parse Linux stat's parenthesized comm safely, including embedded spaces and ')' characters. */
50
+ export function linuxStat(text, hz, at) {
51
+ const start = text.indexOf("(");
52
+ const end = text.lastIndexOf(")");
53
+ if (start < 1 || end <= start || !Number.isFinite(hz) || hz <= 0)
54
+ return null;
55
+ const pid = Number(text.slice(0, start).trim());
56
+ const fields = text
57
+ .slice(end + 1)
58
+ .trim()
59
+ .split(/\s+/);
60
+ const parentPid = Number(fields[1]);
61
+ const user = Number(fields[11]);
62
+ const system = Number(fields[12]);
63
+ const identity = fields[19];
64
+ if (!identity ||
65
+ !/^\d+$/.test(identity) ||
66
+ ![pid, parentPid, user, system].every((n) => Number.isSafeInteger(n) && n >= 0) ||
67
+ pid === 0)
68
+ return null;
69
+ return {
70
+ pid,
71
+ parentPid,
72
+ name: safeName(text.slice(start + 1, end)),
73
+ identity: `${pid}:${identity}`,
74
+ rssBytes: 0,
75
+ cpuMs: ((user + system) / hz) * 1000,
76
+ at,
77
+ };
78
+ }
79
+ /** RSS comes from status in KiB, avoiding an assumed host page size. */
80
+ export function linuxRss(text) {
81
+ const match = /^VmRSS:\s+(\d+)\s+kB$/m.exec(text);
82
+ if (!match?.[1])
83
+ return null;
84
+ const bytes = Number(match[1]) * 1024;
85
+ return Number.isSafeInteger(bytes) ? bytes : null;
86
+ }
87
+ /** Darwin lstart has only one-second identity precision; do not derive interval CPU from it. */
88
+ export function darwinProcess(line, at) {
89
+ 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);
90
+ if (!match?.[1] || !match[2] || !match[3] || !match[5] || match[6] === undefined)
91
+ return null;
92
+ const pid = Number(match[1]);
93
+ const parentPid = Number(match[2]);
94
+ const rssBytes = Number(match[5]) * 1024;
95
+ if (![pid, parentPid, rssBytes].every(Number.isSafeInteger))
96
+ return null;
97
+ return { pid, parentPid, rssBytes, identity: `${pid}:${match[3]}`, name: safeName(match[6]), cpuMs: 0, at };
98
+ }
99
+ /** Parse native PSI percentages and cumulative stall microseconds, rejecting malformed values. */
100
+ export function psi(text) {
101
+ const rows = new Map();
102
+ for (const line of text.trim().split("\n")) {
103
+ const match = /^(some|full) avg10=(\d+(?:\.\d+)?) avg60=(\d+(?:\.\d+)?) avg300=(\d+(?:\.\d+)?) total=(\d+)$/.exec(line);
104
+ if (!match?.[1])
105
+ return null;
106
+ const [avg10, avg60, avg300, total] = match.slice(2).map(Number);
107
+ if (avg10 === undefined ||
108
+ avg60 === undefined ||
109
+ avg300 === undefined ||
110
+ total === undefined ||
111
+ ![avg10, avg60, avg300].every((n) => Number.isFinite(n) && n >= 0 && n <= 100) ||
112
+ !Number.isSafeInteger(total))
113
+ return null;
114
+ rows.set(match[1], { avg10, avg60, avg300, total });
115
+ }
116
+ const some = rows.get("some");
117
+ return some ? { some, full: rows.get("full") ?? null } : null;
118
+ }
@@ -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 { 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 = 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,78 @@
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
+ name: string;
14
+ identity: string;
15
+ rssBytes: number;
16
+ /** Interval utilization: 100 is one logical core, values may exceed 100. */
17
+ cpu: Metric<number>;
18
+ }
19
+ /** Linux pressure stall information, not memory utilization or a universal pressure score. */
20
+ export interface PsiLine {
21
+ avg10: number;
22
+ avg60: number;
23
+ avg300: number;
24
+ /** Cumulative microseconds stalled. */
25
+ total: number;
26
+ }
27
+ /** Snapshot captured over a bounded, non-atomic scan. */
28
+ export interface ResourceSnapshot {
29
+ version: 1;
30
+ platform: string;
31
+ wallTimeMs: number;
32
+ monotonicTimeMs: number;
33
+ durationMs: number;
34
+ /** Aggregate host interval utilization, 0..100. */
35
+ cpu: Metric<number>;
36
+ logicalCpus: number;
37
+ loadAverage: number[];
38
+ memory: {
39
+ totalBytes: number;
40
+ freeBytes: number;
41
+ };
42
+ processes: ProcessSample[];
43
+ coverage: {
44
+ complete: boolean;
45
+ /** Entries encountered, not a claimed host-wide process count when incomplete. */
46
+ enumerated: number;
47
+ sampled: number;
48
+ skipped: number;
49
+ bytesRead: number;
50
+ reasons: string[];
51
+ };
52
+ pressure: {
53
+ source: "linux-psi";
54
+ memory: Metric<{
55
+ some: PsiLine;
56
+ full: PsiLine | null;
57
+ }>;
58
+ } | {
59
+ source: "unsupported";
60
+ reason: string;
61
+ };
62
+ }
63
+ /** Bounds per sample; filesystem/kernel scheduling can delay completion past the deadline. */
64
+ export interface SamplerOptions {
65
+ /** Default 250ms, range 10..5000. No new work after this monotonic deadline. */
66
+ maxDurationMs?: number;
67
+ /** Default 2048, range 1..16384. Sequential Linux reads (concurrency one). */
68
+ maxPids?: number;
69
+ /** Default 2MiB, range 4096..16MiB, includes helper stdout/stderr and proc contents. */
70
+ maxBytes?: number;
71
+ }
72
+ /** Caller owns cadence. No background polling, caching across instances or Pi dependency. */
73
+ export interface ResourceSampler {
74
+ /** Rejects concurrent calls, disposal and caller cancellation. */
75
+ sample(signal?: AbortSignal): Promise<ResourceSnapshot>;
76
+ /** Aborts and awaits any collection and child reaping. Idempotent. */
77
+ dispose(): Promise<void>;
78
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,9 +1,22 @@
1
1
  {
2
2
  "name": "@nredd/pi-resource-sampler",
3
- "version": "0.0.0-bootstrap.0",
4
- "description": "Namespace bootstrap only. Not the resource sampler implementation.",
3
+ "version": "0.1.0",
4
+ "description": "Bounded, privacy-safe host resource interval sampling without Pi dependencies.",
5
+ "type": "module",
5
6
  "license": "MIT",
6
- "repository": "github:nredd/pi-harness",
7
+ "author": "nredd",
7
8
  "publishConfig": { "access": "public" },
8
- "files": ["README.md"]
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
+ "check": "npm run lint && npm run typecheck && npm run build && npm test"
20
+ },
21
+ "devDependencies": { "@biomejs/biome": "2.5.15", "@types/node": "26.6.5", "typescript": "7.0.2", "vitest": "5.0.3" }
9
22
  }