@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 +18 -0
- package/README.md +102 -2
- package/dist/collect.d.ts +40 -0
- package/dist/collect.js +231 -0
- package/dist/counters.d.ts +44 -0
- package/dist/counters.js +141 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +132 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.js +1 -0
- package/package.json +21 -4
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
|
-
#
|
|
1
|
+
# Resource sampler
|
|
2
2
|
|
|
3
|
-
|
|
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"]>;
|
package/dist/collect.js
ADDED
|
@@ -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;
|
package/dist/counters.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|