@nredd/pi-resource-sampler 0.1.0 → 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/README.md CHANGED
@@ -2,7 +2,12 @@
2
2
 
3
3
  Pi-independent, dependency-free ESM library for passive host resource dashboards and snapshots.
4
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.
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.
6
11
 
7
12
  ```ts
8
13
  import { createResourceSampler } from '@nredd/pi-resource-sampler';
@@ -27,10 +32,12 @@ Semantics:
27
32
  - `processes[].cpu`: Linux user+system counter delta divided by monotonic elapsed milliseconds.
28
33
  100 means one logical core, multi-threaded processes can exceed 100. Linux `CLK_TCK` is read
29
34
  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
35
+ - Linux identity includes the exact `/proc/PID/stat` start tick string. Read stat/exe/status/stat and
31
36
  reject identity changes during RSS collection. Missing or inaccessible processes are skipped and
32
37
  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`.
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`.
34
41
  Actual macOS observation: `time` prints hundredths (e.g. `185:32.40`), but `lstart` prints only
35
42
  seconds (`Sat Oct 3 01:49:05 2026`). That cannot rule out PID reuse within one second, so Darwin
36
43
  process interval CPU is explicitly `unavailable`. No decaying `%cpu` is relabelled as an interval.
@@ -40,15 +47,34 @@ Semantics:
40
47
  - `memory.freeBytes` means free, NOT available or pressure. `loadAverage` is load, NOT percent.
41
48
  Linux memory PSI preserves native `some`/optional `full`, avg10/60/300 percentages and cumulative
42
49
  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.
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.
46
66
 
47
67
  Bounds:
48
68
 
49
69
  - Default scan budget 250ms, 2048 PIDs, 2MiB captured bytes; options allow at most 5s, 16384 PIDs,
50
70
  16MiB. Linux proc files read sequentially (concurrency one), at most 8KiB each. Directory iteration
51
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 `""`.
52
78
  - `coverage.complete`, `enumerated`, `sampled`, `skipped`, `bytesRead` and reasons describe partial
53
79
  coverage explicitly. `enumerated` is encountered rows, not total processes when partial. Truncation
54
80
  is not a host-wide top-process ranking. OS enumeration order may favor the same subset each time.
@@ -67,7 +93,10 @@ scans (2.6ms/sample, excludes helper CPU); a separate fixed `ps` invocation meas
67
93
  real-input gate and reports its snapshot duration/coverage.
68
94
 
69
95
  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
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
71
100
  an isolated installed tarball, with no Pi or siblings. Both run on existing macOS/Linux CI jobs.
72
101
  `dist` contains compiled ESM and declarations; the exact packed payload is contract-pinned. No install
73
102
  scripts or runtime dependencies. New npm-name ownership bootstrap requires the owner's interactive
package/dist/collect.d.ts CHANGED
@@ -15,6 +15,14 @@ export declare class Budget {
15
15
  ready(): boolean;
16
16
  /** Fixed-size proc read: no unbounded readFile allocation, and always await handle closure. */
17
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>;
18
26
  }
19
27
  /** Fixed executable/argv only. Bound capture, kill on cancellation/deadline, await close/reaping. */
20
28
  export declare function command(executable: string, args: string[], budget: Budget, maxLines?: number): Promise<{
@@ -23,7 +31,10 @@ export declare function command(executable: string, args: string[], budget: Budg
23
31
  }>;
24
32
  /** Linux scan is sequential and closes the directory even on cancellation. */
25
33
  export declare function linuxProcesses(budget: Budget, hz: number): Promise<ProcessCounter[]>;
26
- /** Fixed metadata-only ps format; -c suppresses executable paths and arguments on Darwin. */
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
+ */
27
38
  export declare function darwinProcesses(budget: Budget): Promise<ProcessCounter[]>;
28
39
  /** PSI is optional and platform-specific; absence is not zero pressure. */
29
40
  export declare function pressure(budget: Budget, platform: string): Promise<ResourceSnapshot["pressure"]>;
package/dist/collect.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
- import { open, opendir } from "node:fs/promises";
2
+ import { open, opendir, readlink } from "node:fs/promises";
3
3
  import { performance } from "node:perf_hooks";
4
- import { darwinProcess, linuxRss, linuxStat, psi } from "./counters.js";
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;
5
7
  /** Per-scan mutable budget, never shared between sampler instances. */
6
8
  export class Budget {
7
9
  deadline;
@@ -52,6 +54,38 @@ export class Budget {
52
54
  await file.close();
53
55
  }
54
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
+ }
55
89
  }
56
90
  /** Fixed executable/argv only. Bound capture, kill on cancellation/deadline, await close/reaping. */
57
91
  export async function command(executable, args, budget, maxLines = Number.POSITIVE_INFINITY) {
@@ -135,11 +169,15 @@ export async function linuxProcesses(budget, hz) {
135
169
  try {
136
170
  const path = `/proc/${entry.name}`;
137
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`);
138
175
  const rssBytes = linuxRss(await budget.read(`${path}/status`));
139
176
  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)
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)
141
179
  throw new Error("process changed or incomplete");
142
- result.push({ ...last, rssBytes });
180
+ result.push({ ...last, name, rssBytes });
143
181
  }
144
182
  catch {
145
183
  budget.signal.throwIfAborted();
@@ -149,9 +187,12 @@ export async function linuxProcesses(budget, hz) {
149
187
  }
150
188
  return result;
151
189
  }
152
- /** Fixed metadata-only ps format; -c suppresses executable paths and arguments on Darwin. */
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
+ */
153
194
  export async function darwinProcesses(budget) {
154
- const { text } = await command("/bin/ps", ["-A", "-c", "-o", "pid=,ppid=,lstart=,time=,rss=,comm="], budget, budget.maxPids);
195
+ const { text } = await command("/bin/ps", ["-A", "-o", "pid=,ppid=,lstart=,time=,rss=,ucomm="], budget, budget.maxPids);
155
196
  const result = [];
156
197
  for (const line of text.split("\n")) {
157
198
  if (!line.trim())
@@ -10,17 +10,32 @@ export interface ProcessCounter {
10
10
  cpuMs: number;
11
11
  at: number;
12
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[];
13
18
  /** Compute an aggregate interval, validating each core and each counter before summing. */
14
19
  export declare function hostCpu(previous: CpuInfo[] | undefined, current: CpuInfo[]): Metric<number>;
15
20
  /** CPU milliseconds divided by monotonic wall milliseconds, not a lifetime/decaying ps average. */
16
21
  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. */
22
+ /** Bound display names by code point and replace terminal controls; directories never leave the parser. */
18
23
  export declare function safeName(value: string): string;
19
- /** Parse Linux stat's parenthesized comm safely, including embedded spaces and ')' characters. */
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
+ */
20
31
  export declare function linuxStat(text: string, hz: number, at: number): ProcessCounter | null;
21
32
  /** RSS comes from status in KiB, avoiding an assumed host page size. */
22
33
  export declare function linuxRss(text: string): number | null;
23
- /** Darwin lstart has only one-second identity precision; do not derive interval CPU from it. */
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
+ */
24
39
  export declare function darwinProcess(line: string, at: number): ProcessCounter | null;
25
40
  /** Parse native PSI percentages and cumulative stall microseconds, rejecting malformed values. */
26
41
  export declare function psi(text: string): {
package/dist/counters.js CHANGED
@@ -1,4 +1,15 @@
1
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
+ }
2
13
  /** Compute an aggregate interval, validating each core and each counter before summing. */
3
14
  export function hostCpu(previous, current) {
4
15
  if (!previous || previous.length !== current.length || current.length === 0) {
@@ -40,13 +51,21 @@ export function processCpu(previous, current) {
40
51
  }
41
52
  return { state: "available", value: (cpu / elapsed) * 100 };
42
53
  }
43
- /** Bound display names and strip terminal controls; paths/arguments never leave the parser. */
54
+ /** Bound display names by code point and replace terminal controls; directories never leave the parser. */
44
55
  export function safeName(value) {
45
- return basename(value)
46
- .replace(/[\p{C}]/gu, "?")
47
- .slice(0, 128);
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);
48
63
  }
49
- /** Parse Linux stat's parenthesized comm safely, including embedded spaces and ')' characters. */
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
+ */
50
69
  export function linuxStat(text, hz, at) {
51
70
  const start = text.indexOf("(");
52
71
  const end = text.lastIndexOf(")");
@@ -69,7 +88,7 @@ export function linuxStat(text, hz, at) {
69
88
  return {
70
89
  pid,
71
90
  parentPid,
72
- name: safeName(text.slice(start + 1, end)),
91
+ name: "",
73
92
  identity: `${pid}:${identity}`,
74
93
  rssBytes: 0,
75
94
  cpuMs: ((user + system) / hz) * 1000,
@@ -84,7 +103,11 @@ export function linuxRss(text) {
84
103
  const bytes = Number(match[1]) * 1024;
85
104
  return Number.isSafeInteger(bytes) ? bytes : null;
86
105
  }
87
- /** Darwin lstart has only one-second identity precision; do not derive interval CPU from it. */
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
+ */
88
111
  export function darwinProcess(line, at) {
89
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);
90
113
  if (!match?.[1] || !match[2] || !match[3] || !match[5] || match[6] === undefined)
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { cpus, freemem, loadavg, platform, totalmem } from "node:os";
2
2
  import { performance } from "node:perf_hooks";
3
3
  import { Budget, command, darwinProcesses, linuxProcesses, pressure } from "./collect.js";
4
- import { hostCpu, processCpu } from "./counters.js";
4
+ import { cpuSnapshot, hostCpu, processCpu } from "./counters.js";
5
5
  /** Validate bounded integers before collecting any host information. */
6
6
  function bounded(value, fallback, min, max) {
7
7
  const result = value ?? fallback;
@@ -27,7 +27,7 @@ export function createResourceSampler(options = {}) {
27
27
  const wallTimeMs = Date.now();
28
28
  const budget = new Budget(start + duration, maxBytes, maxPids, signal);
29
29
  signal.throwIfAborted();
30
- const currentCpus = cpus();
30
+ const currentCpus = cpuSnapshot(cpus());
31
31
  const cpu = previousTime !== undefined && start <= previousTime
32
32
  ? { state: "warming", reason: "invalid monotonic interval" }
33
33
  : hostCpu(previousCpus, currentCpus);
package/dist/types.d.ts CHANGED
@@ -10,6 +10,12 @@ export type Metric<T> = {
10
10
  export interface ProcessSample {
11
11
  pid: number;
12
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
+ */
13
19
  name: string;
14
20
  identity: string;
15
21
  rssBytes: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nredd/pi-resource-sampler",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Bounded, privacy-safe host resource interval sampling without Pi dependencies.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,7 +16,8 @@
16
16
  "lint": "biome check src test",
17
17
  "typecheck": "tsc --noEmit",
18
18
  "test": "vitest run --maxWorkers=1",
19
- "check": "npm run lint && npm run typecheck && npm run build && npm test"
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"
20
21
  },
21
22
  "devDependencies": { "@biomejs/biome": "2.5.15", "@types/node": "26.6.5", "typescript": "7.0.2", "vitest": "5.0.3" }
22
23
  }