@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 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.50 at build time. Re-run `ppm export skill --install` to refresh. -->
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.50 -->
291
+ <!-- Generated from src/server/routes/ for PPM v0.17.51 -->
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hienlh/ppm",
3
- "version": "0.17.50",
3
+ "version": "0.17.51",
4
4
  "description": "Personal Project Manager — mobile-first web IDE with AI assistance",
5
5
  "author": "hienlh",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -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 `maxDirs`, so part of the tree is unwatched. */
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 { dirs: this.covered, watchers: this.attached.size, truncated: this.truncated };
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 proc = Bun.spawn({
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 {