@hienlh/ppm 0.17.50 → 0.17.51
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/CHANGELOG.md +6 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/docs/lessons-learned.md +62 -0
- package/package.json +1 -1
- package/src/services/file-watcher/recreated-dir-poller.ts +149 -0
- package/src/services/file-watcher/watch-tree.ts +58 -3
- package/src/services/proc-table-linux.ts +183 -0
- package/src/services/resource-monitor.service.ts +33 -9
- package/src/services/tunnel-registry.service.ts +18 -0
- package/src/services/windows-process-tree.ts +351 -335
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.17.51] - 2026-09-01
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **File changes are reported again after a directory is deleted and recreated (Linux)** — switching git branches, or any `rm -rf x && mkdir x`, left everything under that directory silently unwatched until a restart. The runtime cannot revive a watch on such a path, so PPM now falls back to polling just those directories.
|
|
7
|
+
- **The resource panel and tunnel list no longer come back empty on slim Linux images** — both read the process table through `ps`, which minimal containers do not ship, and treated the missing command as "no processes". They read the kernel directly now.
|
|
8
|
+
|
|
3
9
|
## [0.17.50] - 2026-09-01
|
|
4
10
|
|
|
5
11
|
### Fixed
|
|
@@ -71,4 +71,4 @@ This skill covers the `ppm` CLI, its HTTP API, and its config DB. It does **not*
|
|
|
71
71
|
- Third-party extensions (inspect via `ppm ext list`).
|
|
72
72
|
- The Claude Agent SDK internals (separate skill).
|
|
73
73
|
|
|
74
|
-
<!-- Generated for PPM v0.17.
|
|
74
|
+
<!-- Generated for PPM v0.17.51 at build time. Re-run `ppm export skill --install` to refresh. -->
|
|
@@ -288,4 +288,4 @@ _Base URL: `http://localhost:8080` (default; override via `ppm config set port <
|
|
|
288
288
|
- `ws://<host>/ws/terminal` — PTY terminal multiplexer
|
|
289
289
|
- `ws://<host>/ws/extensions` — extension host channel
|
|
290
290
|
|
|
291
|
-
<!-- Generated from src/server/routes/ for PPM v0.17.
|
|
291
|
+
<!-- Generated from src/server/routes/ for PPM v0.17.51 -->
|
package/docs/lessons-learned.md
CHANGED
|
@@ -222,3 +222,65 @@ Adopt first; probe only when there is nothing to adopt.
|
|
|
222
222
|
Related: `findPortListenerPid` needs `netstat` on Windows and `lsof` on POSIX, and returns `0` when
|
|
223
223
|
the tool is missing. Treating "cannot tell" as "does not match" refused every adoption on such a
|
|
224
224
|
box and spawned a duplicate edge that then could not bind.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
228
|
+
## Process enumeration must not depend on `ps`
|
|
229
|
+
|
|
230
|
+
**Problem**: `collectProcessTree` and `isPpmProcess` shelled out to `ps`, which ships in `procps` —
|
|
231
|
+
a package slim Debian images leave out, including the one PPM's own suite runs in. Both functions
|
|
232
|
+
swallow the spawn error and return "no descendants" / "not a PPM process", so a missing binary
|
|
233
|
+
**silently disables orphan reaping** rather than failing loudly. Two tests had been timing out for
|
|
234
|
+
weeks and were written off as environmental.
|
|
235
|
+
|
|
236
|
+
**Fix**: read `/proc` directly on Linux (`/proc/<pid>/stat` for the pid→ppid map,
|
|
237
|
+
`/proc/<pid>/cmdline` for argv) and keep `ps` only as the macOS path. No subprocess, no hidden
|
|
238
|
+
dependency, and much faster — the two tests went from 5s timeouts to ~100ms.
|
|
239
|
+
|
|
240
|
+
Parsing note: `/proc/<pid>/stat` is `pid (comm) state ppid …` and `comm` may contain spaces **and
|
|
241
|
+
parentheses**, so anchor on the last `)` instead of splitting the line naively.
|
|
242
|
+
|
|
243
|
+
All call sites now go through `src/services/proc-table-linux.ts`, which reads the table once per
|
|
244
|
+
call and derives what each caller used to ask `ps` for:
|
|
245
|
+
|
|
246
|
+
| `ps` column | `/proc` source |
|
|
247
|
+
|---|---|
|
|
248
|
+
| `pid`, `ppid` | `/proc/<pid>/stat` fields 1 and 4 |
|
|
249
|
+
| `%cpu` | `(utime+stime)/HZ / elapsed` — fields 14, 15, 22 plus `/proc/uptime` |
|
|
250
|
+
| `rss` | field 24 (pages) × page size |
|
|
251
|
+
| `etimes` | `uptime − starttime/HZ` |
|
|
252
|
+
| `lstart` | `btime` from `/proc/stat` + `starttime/HZ` |
|
|
253
|
+
| `args` | `/proc/<pid>/cmdline` (NUL-separated) |
|
|
254
|
+
| `comm` | `/proc/<pid>/comm` |
|
|
255
|
+
|
|
256
|
+
`HZ` is assumed to be 100 — `sysconf(_SC_CLK_TCK)` is unreachable from JS, and every mainstream
|
|
257
|
+
Linux ships 100. A wrong value would skew a CPU percentage, nothing more.
|
|
258
|
+
|
|
259
|
+
## Bun on Linux cannot re-watch a deleted-and-recreated directory
|
|
260
|
+
|
|
261
|
+
**Problem**: `WatchTree` releases a watcher when a directory disappears and re-covers it when it
|
|
262
|
+
comes back. On Bun 1.3.13 + Linux the new watcher is silent forever: Bun keys its `fs.watch`
|
|
263
|
+
registry by the literal path string and reuses the dead inotify watch. Closing the old handle
|
|
264
|
+
first, or waiting seconds before re-watching, makes no difference.
|
|
265
|
+
|
|
266
|
+
**Evidence** (`spike-bun-recursive-watch-probe.mjs`): plain recursive and non-recursive watches
|
|
267
|
+
both deliver; both go silent for a recreated directory. On **Windows** (bun 1.3.10) every case
|
|
268
|
+
delivers, so the defect is Linux-only.
|
|
269
|
+
|
|
270
|
+
**No clean workaround.** A trailing separator is a different key and works exactly once; `//` and
|
|
271
|
+
`///` normalise to the same key, so a rotating-spelling scheme fails from the second cycle.
|
|
272
|
+
|
|
273
|
+
**The poisoning does not spread**, and that is what made a fix affordable: a directory Bun has
|
|
274
|
+
never watched works normally even inside a recreated parent
|
|
275
|
+
(`spike-bun-watch-poison-scope-probe.mjs`). So only paths that were actually re-attached are dead.
|
|
276
|
+
|
|
277
|
+
**Fix**: `WatchTree` remembers every path it has handed to `fs.watch`. Re-attaching one of them
|
|
278
|
+
means that directory was deleted and recreated, so on Linux it hands the directory to
|
|
279
|
+
`RecreatedDirPoller` (readdir + mtime diff, 1s interval, hard cap of 64 directories) and covers the
|
|
280
|
+
subtree non-recursively so each child gets a watcher on a path the runtime still honours. Windows
|
|
281
|
+
and macOS never construct the poller.
|
|
282
|
+
|
|
283
|
+
`WatchTreeStats.polledDirs` reports how many directories are on the degraded path, and the cap
|
|
284
|
+
being hit sets `truncated` — so a churn storm shows up in stats instead of silently growing the
|
|
285
|
+
poll set. Given this watcher once reached ~360k inotify watches, refusing to grow without limit
|
|
286
|
+
matters more than perfect coverage.
|
package/package.json
CHANGED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Polling fallback for directories `fs.watch` can no longer report on.
|
|
3
|
+
*
|
|
4
|
+
* Bun (verified on 1.3.13, Linux) keys its `fs.watch` registry by the literal
|
|
5
|
+
* path string, so once a directory has been watched, deleted and recreated, a
|
|
6
|
+
* new watcher on that same path reuses the dead inotify watch and is silent
|
|
7
|
+
* forever. Closing the old handle first or waiting does not help, and there is
|
|
8
|
+
* no usable alternative spelling: a trailing separator is a distinct key that
|
|
9
|
+
* works exactly once, while `//` and `///` normalise back to the same one.
|
|
10
|
+
*
|
|
11
|
+
* The poisoning is per-path and permanent, but it does NOT spread: a directory
|
|
12
|
+
* Bun has never watched works normally even when it sits inside a recreated
|
|
13
|
+
* parent. So this poller only ever covers the handful of paths that were
|
|
14
|
+
* actually re-attached, one directory level each, and real watchers keep
|
|
15
|
+
* covering everything else.
|
|
16
|
+
*
|
|
17
|
+
* Reproducers: `spike-bun-recursive-watch-probe.mjs` (the defect, and that
|
|
18
|
+
* Windows is unaffected) and `spike-bun-watch-poison-scope-probe.mjs` (that it
|
|
19
|
+
* is confined to previously-watched paths, which is what bounds this poller).
|
|
20
|
+
*
|
|
21
|
+
* Windows and macOS re-watch such directories correctly, so nothing here runs
|
|
22
|
+
* there.
|
|
23
|
+
*/
|
|
24
|
+
import { lstatSync, readdirSync } from "node:fs";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
/** Slow enough to be invisible next to inotify, fast enough for a save-and-see loop. */
|
|
28
|
+
const POLL_INTERVAL_MS = 1000;
|
|
29
|
+
/**
|
|
30
|
+
* Hard cap on polled directories. Churn is bounded in practice (a `git checkout`
|
|
31
|
+
* recreates a few directories), and refusing to grow without limit matters more
|
|
32
|
+
* than perfect coverage in a watcher that has already caused a watch-count
|
|
33
|
+
* blowup once.
|
|
34
|
+
*/
|
|
35
|
+
const MAX_POLLED_DIRS = 64;
|
|
36
|
+
|
|
37
|
+
export interface RecreatedDirPollerOptions {
|
|
38
|
+
/** Absolute path of every entry that appeared, vanished or changed. */
|
|
39
|
+
onChange: (absPath: string) => void;
|
|
40
|
+
intervalMs?: number;
|
|
41
|
+
maxDirs?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** name → mtimeMs for the direct entries of one directory. */
|
|
45
|
+
type DirSnapshot = Map<string, number>;
|
|
46
|
+
|
|
47
|
+
export class RecreatedDirPoller {
|
|
48
|
+
private readonly snapshots = new Map<string, DirSnapshot>();
|
|
49
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
50
|
+
private readonly intervalMs: number;
|
|
51
|
+
private readonly maxDirs: number;
|
|
52
|
+
private droppedForBudget = false;
|
|
53
|
+
|
|
54
|
+
constructor(private readonly options: RecreatedDirPollerOptions) {
|
|
55
|
+
this.intervalMs = options.intervalMs ?? POLL_INTERVAL_MS;
|
|
56
|
+
this.maxDirs = options.maxDirs ?? MAX_POLLED_DIRS;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get size(): number {
|
|
60
|
+
return this.snapshots.size;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when a directory had to be refused because the cap was reached. */
|
|
64
|
+
get truncated(): boolean {
|
|
65
|
+
return this.droppedForBudget;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Start polling `absDir`'s direct entries. The current contents become the
|
|
70
|
+
* baseline, so pre-existing files are not reported as new.
|
|
71
|
+
*/
|
|
72
|
+
add(absDir: string): void {
|
|
73
|
+
if (this.snapshots.has(absDir)) return;
|
|
74
|
+
if (this.snapshots.size >= this.maxDirs) {
|
|
75
|
+
this.droppedForBudget = true;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
this.snapshots.set(absDir, this.readDir(absDir));
|
|
79
|
+
if (!this.timer) {
|
|
80
|
+
this.timer = setInterval(() => this.tick(), this.intervalMs);
|
|
81
|
+
// Never hold the process open just to poll.
|
|
82
|
+
this.timer.unref?.();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Stop polling `absDir` and anything beneath it. */
|
|
87
|
+
remove(absDir: string): void {
|
|
88
|
+
const prefix = absDir + "/";
|
|
89
|
+
for (const dir of this.snapshots.keys()) {
|
|
90
|
+
// Compare on both separators: callers pass native paths.
|
|
91
|
+
if (dir === absDir || dir.startsWith(prefix) || dir.startsWith(absDir + "\\")) {
|
|
92
|
+
this.snapshots.delete(dir);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (this.snapshots.size === 0) this.stopTimer();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
close(): void {
|
|
99
|
+
this.snapshots.clear();
|
|
100
|
+
this.droppedForBudget = false;
|
|
101
|
+
this.stopTimer();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private stopTimer(): void {
|
|
105
|
+
if (this.timer) {
|
|
106
|
+
clearInterval(this.timer);
|
|
107
|
+
this.timer = null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private tick(): void {
|
|
112
|
+
for (const [dir, previous] of this.snapshots) {
|
|
113
|
+
const current = this.readDir(dir);
|
|
114
|
+
|
|
115
|
+
for (const [name, mtime] of current) {
|
|
116
|
+
const before = previous.get(name);
|
|
117
|
+
if (before === undefined || before !== mtime) {
|
|
118
|
+
this.options.onChange(join(dir, name));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
for (const name of previous.keys()) {
|
|
122
|
+
if (!current.has(name)) this.options.onChange(join(dir, name));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.snapshots.set(dir, current);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Direct entries of `absDir` with their mtimes. Empty when it is unreadable. */
|
|
130
|
+
private readDir(absDir: string): DirSnapshot {
|
|
131
|
+
const snapshot: DirSnapshot = new Map();
|
|
132
|
+
let names: string[];
|
|
133
|
+
try {
|
|
134
|
+
names = readdirSync(absDir);
|
|
135
|
+
} catch {
|
|
136
|
+
return snapshot; // deleted again, or permissions — treat as empty
|
|
137
|
+
}
|
|
138
|
+
for (const name of names) {
|
|
139
|
+
try {
|
|
140
|
+
// lstat, not stat: a symlink's own mtime, never its target's, matching
|
|
141
|
+
// the scan that decides coverage.
|
|
142
|
+
snapshot.set(name, lstatSync(join(absDir, name)).mtimeMs);
|
|
143
|
+
} catch {
|
|
144
|
+
// Vanished between readdir and lstat; the next tick reports it.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return snapshot;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { lstatSync, readdirSync, watch, type FSWatcher } from "node:fs";
|
|
2
2
|
import { join, relative, sep } from "node:path";
|
|
3
3
|
import { hasIgnoredDirSegment, isIgnoredDirName, isIgnoredPath } from "./ignore-rules.ts";
|
|
4
|
+
import { RecreatedDirPoller } from "./recreated-dir-poller.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Watches a project directory while keeping the number of watched directories
|
|
@@ -54,18 +55,36 @@ export interface WatchTreeStats {
|
|
|
54
55
|
dirs: number;
|
|
55
56
|
/** `fs.watch` handles held. */
|
|
56
57
|
watchers: number;
|
|
57
|
-
/** Coverage was cut short by
|
|
58
|
+
/** Coverage was cut short by a budget, so part of the tree is unwatched. */
|
|
58
59
|
truncated: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Directories covered by the polling fallback instead of a watcher, because
|
|
62
|
+
* the runtime cannot re-watch a recreated path. Linux only; 0 elsewhere.
|
|
63
|
+
*/
|
|
64
|
+
polledDirs: number;
|
|
59
65
|
}
|
|
60
66
|
|
|
61
67
|
export class WatchTree {
|
|
62
68
|
private readonly attached = new Map<string, AttachedWatcher>();
|
|
63
69
|
private readonly rebuildTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
70
|
+
/**
|
|
71
|
+
* Every path we have ever handed to `fs.watch`. On Bun + Linux a second watch
|
|
72
|
+
* on the same path after a delete/recreate is silent forever, so this set is
|
|
73
|
+
* what tells us a watcher cannot be trusted and the poller has to stand in.
|
|
74
|
+
*/
|
|
75
|
+
private readonly everAttached = new Set<string>();
|
|
76
|
+
private readonly poller: RecreatedDirPoller | null;
|
|
64
77
|
private covered = 0;
|
|
65
78
|
private truncated = false;
|
|
66
79
|
private closed = false;
|
|
67
80
|
|
|
68
|
-
constructor(private readonly options: WatchTreeOptions) {
|
|
81
|
+
constructor(private readonly options: WatchTreeOptions) {
|
|
82
|
+
// Only Bun on Linux has the stale-watch defect; elsewhere re-watching works
|
|
83
|
+
// and paying for polling would be pure waste.
|
|
84
|
+
this.poller = process.platform === "linux"
|
|
85
|
+
? new RecreatedDirPoller({ onChange: (abs) => this.reportAbs(abs) })
|
|
86
|
+
: null;
|
|
87
|
+
}
|
|
69
88
|
|
|
70
89
|
start(): void {
|
|
71
90
|
this.cover(this.options.root);
|
|
@@ -79,12 +98,19 @@ export class WatchTree {
|
|
|
79
98
|
try { watcher.close(); } catch { /* already gone */ }
|
|
80
99
|
}
|
|
81
100
|
this.attached.clear();
|
|
101
|
+
this.everAttached.clear();
|
|
102
|
+
this.poller?.close();
|
|
82
103
|
this.covered = 0;
|
|
83
104
|
this.truncated = false;
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
stats(): WatchTreeStats {
|
|
87
|
-
return {
|
|
108
|
+
return {
|
|
109
|
+
dirs: this.covered,
|
|
110
|
+
watchers: this.attached.size,
|
|
111
|
+
truncated: this.truncated || (this.poller?.truncated ?? false),
|
|
112
|
+
polledDirs: this.poller?.size ?? 0,
|
|
113
|
+
};
|
|
88
114
|
}
|
|
89
115
|
|
|
90
116
|
/** Walk `absDir` and attach the fewest watchers that cover it without touching ignored dirs. */
|
|
@@ -134,6 +160,22 @@ export class WatchTree {
|
|
|
134
160
|
|
|
135
161
|
private attach(node: ScanNode): void {
|
|
136
162
|
const fitsWholeSubtree = this.covered + node.size <= this.options.maxDirs;
|
|
163
|
+
|
|
164
|
+
// A path we have watched before is being re-attached, so this directory was
|
|
165
|
+
// deleted and recreated. On Bun + Linux its watcher will never fire again:
|
|
166
|
+
// poll its own entries, and cover the subtree non-recursively so each child
|
|
167
|
+
// gets a watcher on a path the runtime still honours.
|
|
168
|
+
if (this.poller && this.everAttached.has(node.path)) {
|
|
169
|
+
this.poller.add(node.path);
|
|
170
|
+
if (this.covered + 1 > this.options.maxDirs) {
|
|
171
|
+
this.truncated = true;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
this.addWatcher(node.path, false, 1);
|
|
175
|
+
for (const child of node.dirs) this.attach(child);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
137
179
|
// One recursive watch for a subtree the runtime can safely expand on its own.
|
|
138
180
|
if (!node.hasIgnored && fitsWholeSubtree && this.addWatcher(node.path, true, node.size)) return;
|
|
139
181
|
|
|
@@ -158,6 +200,7 @@ export class WatchTree {
|
|
|
158
200
|
// server down; drop the handle instead and record the lost coverage.
|
|
159
201
|
watcher.on("error", () => this.dropWatcher(absDir));
|
|
160
202
|
this.attached.set(absDir, { watcher, covers, recursive });
|
|
203
|
+
this.everAttached.add(absDir);
|
|
161
204
|
this.covered += covers;
|
|
162
205
|
return true;
|
|
163
206
|
} catch {
|
|
@@ -203,6 +246,17 @@ export class WatchTree {
|
|
|
203
246
|
if (!isIgnoredPath(relPath)) this.options.onChange(relPath);
|
|
204
247
|
}
|
|
205
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Report an absolute path the poller noticed, applying the same root-scoping
|
|
251
|
+
* and ignore rules a watcher event goes through.
|
|
252
|
+
*/
|
|
253
|
+
private reportAbs(abs: string): void {
|
|
254
|
+
if (this.closed) return;
|
|
255
|
+
const relPath = relative(this.options.root, abs).replaceAll("\\", "/");
|
|
256
|
+
if (!relPath || relPath === ".." || relPath.startsWith("../")) return;
|
|
257
|
+
if (!isIgnoredPath(relPath)) this.options.onChange(relPath);
|
|
258
|
+
}
|
|
259
|
+
|
|
206
260
|
/** Extend or release coverage after a directory under a non-recursive watch appeared or vanished. */
|
|
207
261
|
private syncChildDir(abs: string): void {
|
|
208
262
|
let isDir = false;
|
|
@@ -233,6 +287,7 @@ export class WatchTree {
|
|
|
233
287
|
}
|
|
234
288
|
|
|
235
289
|
private closeSubtree(absPath: string): void {
|
|
290
|
+
this.poller?.remove(absPath);
|
|
236
291
|
const prefix = absPath + sep;
|
|
237
292
|
for (const [dir, entry] of this.attached) {
|
|
238
293
|
if (dir !== absPath && !dir.startsWith(prefix)) continue;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process table straight from `/proc` — Linux only, no subprocess.
|
|
3
|
+
*
|
|
4
|
+
* Every caller in PPM used to shell out to `ps`, which lives in `procps` and is
|
|
5
|
+
* simply absent from slim Debian images (including the one PPM's own test suite
|
|
6
|
+
* runs in). Each call site swallowed the spawn error and carried on with empty
|
|
7
|
+
* data, so a missing binary silently disabled orphan reaping, resource graphs
|
|
8
|
+
* and cloudflared discovery instead of failing loudly.
|
|
9
|
+
*
|
|
10
|
+
* Callers keep `ps` as the macOS path; there is no `/proc` there.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
export const PROC_AVAILABLE = process.platform === "linux";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Kernel clock ticks per second. `sysconf(_SC_CLK_TCK)` is not reachable from
|
|
18
|
+
* JS; 100 is the value Linux has shipped on every mainstream configuration for
|
|
19
|
+
* decades, and it only scales CPU-time maths, so a wrong guess would skew a
|
|
20
|
+
* percentage rather than break anything.
|
|
21
|
+
*/
|
|
22
|
+
const CLOCK_TICKS_PER_SEC = 100;
|
|
23
|
+
/** `/proc/<pid>/stat` reports RSS in pages. */
|
|
24
|
+
const PAGE_SIZE_KB = 4;
|
|
25
|
+
|
|
26
|
+
export interface ProcEntry {
|
|
27
|
+
pid: number;
|
|
28
|
+
ppid: number;
|
|
29
|
+
/** Average CPU% over the process lifetime — the same figure `ps %cpu` prints. */
|
|
30
|
+
cpuPercent: number;
|
|
31
|
+
rssKB: number;
|
|
32
|
+
/** Seconds since the process started. */
|
|
33
|
+
elapsedSec: number;
|
|
34
|
+
/** Wall-clock start time, epoch ms. Stable identity for PID-reuse checks. */
|
|
35
|
+
startedAtMs: number;
|
|
36
|
+
/** Executable name from `comm` (truncated to 15 chars by the kernel). */
|
|
37
|
+
comm: string;
|
|
38
|
+
/** Full argv joined by spaces. Empty for kernel threads. */
|
|
39
|
+
args: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Lowercased argv of `pid`, or null when /proc is unavailable/unreadable. */
|
|
43
|
+
export function readProcCmdline(pid: number): string | null {
|
|
44
|
+
if (!PROC_AVAILABLE) return null;
|
|
45
|
+
try {
|
|
46
|
+
// NUL-separated argv; join with spaces so substring checks behave like `ps`.
|
|
47
|
+
return readFileSync(`/proc/${pid}/cmdline`, "utf-8").split("\0").join(" ").toLowerCase();
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Kernel `comm` for `pid`, or null when /proc is unavailable/unreadable. */
|
|
54
|
+
export function readProcComm(pid: number): string | null {
|
|
55
|
+
if (!PROC_AVAILABLE) return null;
|
|
56
|
+
try {
|
|
57
|
+
return readFileSync(`/proc/${pid}/comm`, "utf-8").trim();
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** pid → ppid for every visible process, or null when /proc is unavailable. */
|
|
64
|
+
export function readProcPpidMap(): Map<number, number> | null {
|
|
65
|
+
if (!PROC_AVAILABLE) return null;
|
|
66
|
+
let names: string[];
|
|
67
|
+
try {
|
|
68
|
+
names = readdirSync("/proc");
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const ppidOf = new Map<number, number>();
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
if (!isPidDir(name)) continue;
|
|
75
|
+
const fields = readStatFields(Number(name));
|
|
76
|
+
if (fields) ppidOf.set(Number(name), fields.ppid);
|
|
77
|
+
}
|
|
78
|
+
return ppidOf.size > 0 ? ppidOf : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Full process table, or null when /proc is unavailable. Reads `/proc/uptime`
|
|
83
|
+
* and `/proc/stat` once and reuses them for every process, so a table of a few
|
|
84
|
+
* hundred processes costs a few hundred small reads and no process spawns.
|
|
85
|
+
*/
|
|
86
|
+
export function readProcTable(): ProcEntry[] | null {
|
|
87
|
+
if (!PROC_AVAILABLE) return null;
|
|
88
|
+
|
|
89
|
+
let names: string[];
|
|
90
|
+
let uptimeSec: number;
|
|
91
|
+
let bootTimeMs: number;
|
|
92
|
+
try {
|
|
93
|
+
names = readdirSync("/proc");
|
|
94
|
+
uptimeSec = parseFloat(readFileSync("/proc/uptime", "utf-8").split(/\s+/)[0] ?? "");
|
|
95
|
+
const btime = readFileSync("/proc/stat", "utf-8").match(/^btime\s+(\d+)/m);
|
|
96
|
+
bootTimeMs = btime ? Number(btime[1]) * 1000 : Date.now() - uptimeSec * 1000;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
if (!Number.isFinite(uptimeSec)) return null;
|
|
101
|
+
|
|
102
|
+
const out: ProcEntry[] = [];
|
|
103
|
+
for (const name of names) {
|
|
104
|
+
if (!isPidDir(name)) continue;
|
|
105
|
+
const pid = Number(name);
|
|
106
|
+
const f = readStatFields(pid);
|
|
107
|
+
if (!f) continue;
|
|
108
|
+
|
|
109
|
+
const startSec = f.startTicks / CLOCK_TICKS_PER_SEC;
|
|
110
|
+
// Clamp: a process started in the same tick as the uptime read can compute
|
|
111
|
+
// a tiny negative elapsed, which would produce an absurd CPU percentage.
|
|
112
|
+
const elapsedSec = Math.max(uptimeSec - startSec, 0.001);
|
|
113
|
+
const cpuSec = (f.utime + f.stime) / CLOCK_TICKS_PER_SEC;
|
|
114
|
+
|
|
115
|
+
let args = "";
|
|
116
|
+
try {
|
|
117
|
+
args = readFileSync(`/proc/${pid}/cmdline`, "utf-8").split("\0").filter(Boolean).join(" ");
|
|
118
|
+
} catch { /* exited between readdir and read */ }
|
|
119
|
+
|
|
120
|
+
out.push({
|
|
121
|
+
pid,
|
|
122
|
+
ppid: f.ppid,
|
|
123
|
+
cpuPercent: Math.round((cpuSec / elapsedSec) * 1000) / 10,
|
|
124
|
+
rssKB: f.rssPages * PAGE_SIZE_KB,
|
|
125
|
+
elapsedSec,
|
|
126
|
+
startedAtMs: bootTimeMs + startSec * 1000,
|
|
127
|
+
comm: f.comm,
|
|
128
|
+
args,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return out.length > 0 ? out : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const isPidDir = (name: string): boolean => /^\d+$/.test(name);
|
|
135
|
+
|
|
136
|
+
interface StatFields {
|
|
137
|
+
comm: string;
|
|
138
|
+
ppid: number;
|
|
139
|
+
utime: number;
|
|
140
|
+
stime: number;
|
|
141
|
+
startTicks: number;
|
|
142
|
+
rssPages: number;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Parse the fields we need out of `/proc/<pid>/stat`.
|
|
147
|
+
*
|
|
148
|
+
* The format is `pid (comm) state ppid …` and `comm` may contain spaces AND
|
|
149
|
+
* parentheses, so the split has to anchor on the LAST ')' — a naive
|
|
150
|
+
* whitespace split silently shifts every field for any process whose name
|
|
151
|
+
* contains a space.
|
|
152
|
+
*/
|
|
153
|
+
function readStatFields(pid: number): StatFields | null {
|
|
154
|
+
let stat: string;
|
|
155
|
+
try {
|
|
156
|
+
stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
157
|
+
} catch {
|
|
158
|
+
return null; // process exited, or not permitted
|
|
159
|
+
}
|
|
160
|
+
const close = stat.lastIndexOf(")");
|
|
161
|
+
const open = stat.indexOf("(");
|
|
162
|
+
if (close < 0 || open < 0 || close < open) return null;
|
|
163
|
+
|
|
164
|
+
const comm = stat.slice(open + 1, close);
|
|
165
|
+
// After the last ')' the fields are: state ppid pgrp … i.e. proc(5) field N
|
|
166
|
+
// sits at index N-3.
|
|
167
|
+
const f = stat.slice(close + 1).trim().split(/\s+/);
|
|
168
|
+
const num = (i: number): number => {
|
|
169
|
+
const v = parseInt(f[i] ?? "", 10);
|
|
170
|
+
return isNaN(v) ? 0 : v;
|
|
171
|
+
};
|
|
172
|
+
const ppid = parseInt(f[1] ?? "", 10);
|
|
173
|
+
if (isNaN(ppid)) return null;
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
comm,
|
|
177
|
+
ppid,
|
|
178
|
+
utime: num(11), // field 14
|
|
179
|
+
stime: num(12), // field 15
|
|
180
|
+
startTicks: num(19), // field 22
|
|
181
|
+
rssPages: num(21), // field 24
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { parseProcessList, buildTree, groupProcesses } from "./resource-monitor-utils.ts";
|
|
8
|
+
import { readProcTable } from "./proc-table-linux.ts";
|
|
8
9
|
|
|
9
10
|
// ── Types ──────────────────────────────────────────────────────────────
|
|
10
11
|
|
|
@@ -76,17 +77,40 @@ class ResourceMonitorService {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Prefer `/proc` and fall back to `ps`. `ps` is not installed on slim Linux
|
|
82
|
+
* images, and the old code let the failed spawn surface as an empty process
|
|
83
|
+
* list — the resource panel just showed zeros with no hint why.
|
|
84
|
+
*/
|
|
85
|
+
private async listProcesses(): Promise<ProcessEntry[]> {
|
|
86
|
+
const table = readProcTable();
|
|
87
|
+
if (table) {
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
return table
|
|
90
|
+
.filter((p) => p.pid !== 0 && p.args)
|
|
91
|
+
.map((p) => ({
|
|
92
|
+
pid: p.pid,
|
|
93
|
+
ppid: p.ppid,
|
|
94
|
+
cpu: Math.round(p.cpuPercent * 10) / 10,
|
|
95
|
+
ramMB: Math.round((p.rssKB / 1024) * 10) / 10,
|
|
96
|
+
startedAt: Math.min(p.startedAtMs, now),
|
|
97
|
+
command: p.args,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const proc = Bun.spawn({
|
|
102
|
+
cmd: ["ps", "-e", "-o", "pid,ppid,%cpu,rss,etimes,args"],
|
|
103
|
+
stdout: "pipe",
|
|
104
|
+
stderr: "ignore",
|
|
105
|
+
});
|
|
106
|
+
const stdout = await new Response(proc.stdout).text();
|
|
107
|
+
await proc.exited;
|
|
108
|
+
return parseProcessList(stdout);
|
|
109
|
+
}
|
|
110
|
+
|
|
79
111
|
private async poll() {
|
|
80
112
|
try {
|
|
81
|
-
const
|
|
82
|
-
cmd: ["ps", "-e", "-o", "pid,ppid,%cpu,rss,etimes,args"],
|
|
83
|
-
stdout: "pipe",
|
|
84
|
-
stderr: "ignore",
|
|
85
|
-
});
|
|
86
|
-
const stdout = await new Response(proc.stdout).text();
|
|
87
|
-
await proc.exited;
|
|
88
|
-
|
|
89
|
-
const entries = parseProcessList(stdout);
|
|
113
|
+
const entries = await this.listProcesses();
|
|
90
114
|
const rootPid = process.pid;
|
|
91
115
|
const serverEntry = entries.find((e) => e.pid === rootPid);
|
|
92
116
|
const children = buildTree(entries, rootPid);
|
|
@@ -14,6 +14,7 @@ import { basename } from "node:path";
|
|
|
14
14
|
import { resolve } from "node:path";
|
|
15
15
|
import { readFileSync, existsSync } from "node:fs";
|
|
16
16
|
import { getPpmDir } from "./ppm-dir.ts";
|
|
17
|
+
import { readProcTable, readProcComm } from "./proc-table-linux.ts";
|
|
17
18
|
import { configService } from "./config.service.ts";
|
|
18
19
|
import {
|
|
19
20
|
parseCloudflaredCmdline,
|
|
@@ -74,6 +75,21 @@ function enumerateWindows(): RawProc[] {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
function enumerateUnix(): RawProc[] {
|
|
78
|
+
// /proc first: `ps` lives in `procps` and slim Linux images omit it, which
|
|
79
|
+
// used to make cloudflared discovery come back empty with no error.
|
|
80
|
+
const table = readProcTable();
|
|
81
|
+
if (table) {
|
|
82
|
+
return table
|
|
83
|
+
.filter((p) => /(^|\/)cloudflared(\s|$)/.test(p.args))
|
|
84
|
+
.map((p) => ({
|
|
85
|
+
pid: p.pid,
|
|
86
|
+
// Same role as ps `lstart`: a start-time identity that survives PID reuse.
|
|
87
|
+
identity: new Date(p.startedAtMs).toISOString(),
|
|
88
|
+
imagePath: p.args.split(/\s+/)[0] ?? "",
|
|
89
|
+
cmdline: p.args,
|
|
90
|
+
}));
|
|
91
|
+
}
|
|
92
|
+
|
|
77
93
|
// pid, lstart (identity), full args. Filter to cloudflared, excluding the grep.
|
|
78
94
|
const out = execFileSync("ps", ["-eo", "pid=,lstart=,args="], { encoding: "utf-8", timeout: 6000 });
|
|
79
95
|
const procs: RawProc[] = [];
|
|
@@ -190,6 +206,8 @@ export function isCloudflaredPid(pid: number): boolean {
|
|
|
190
206
|
);
|
|
191
207
|
return basename(out.trim()).toLowerCase() === "cloudflared.exe";
|
|
192
208
|
}
|
|
209
|
+
const comm = readProcComm(pid);
|
|
210
|
+
if (comm !== null) return basename(comm) === "cloudflared";
|
|
193
211
|
const out = execFileSync("ps", ["-o", "comm=", "-p", String(pid)], { encoding: "utf-8", timeout: 5000 });
|
|
194
212
|
return basename(out.trim()) === "cloudflared";
|
|
195
213
|
} catch {
|