@hienlh/ppm 0.17.49 → 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.
@@ -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);
@@ -1,10 +1,16 @@
1
1
  /**
2
2
  * Minimal HTTP server that serves a "stopped" page when the PPM server child is down.
3
- * Binds to the same port so the tunnel URL still works.
3
+ *
4
+ * It stands in for the server, so it binds an OS-assigned loopback port and
5
+ * publishes it to `.server-port` exactly as the real server does. The edge
6
+ * forwarder then routes the public port here and the tunnel URL keeps working —
7
+ * which is the whole point of the page. Binding the public port directly would
8
+ * collide with the edge, which owns it.
4
9
  */
5
- import { appendFileSync } from "node:fs";
10
+ import { appendFileSync, writeFileSync } from "node:fs";
6
11
  import { resolve } from "node:path";
7
12
  import { getPpmDir } from "./ppm-dir.ts";
13
+ import { SERVER_PORT_FILE } from "./edge-target-resolver.ts";
8
14
 
9
15
  function log(level: string, msg: string) {
10
16
  const ts = new Date().toISOString();
@@ -36,6 +42,11 @@ const STOPPED_HTML = `<!DOCTYPE html>
36
42
 
37
43
  let stoppedServer: ReturnType<typeof Bun.serve> | null = null;
38
44
 
45
+ /**
46
+ * @param port 0 in normal operation — the edge finds this page via
47
+ * `.server-port`, so it needs no fixed port of its own.
48
+ * @param host loopback; the edge is the only public listener.
49
+ */
39
50
  export function startStoppedPage(port: number, host: string) {
40
51
  if (stoppedServer) return;
41
52
 
@@ -56,7 +67,13 @@ export function startStoppedPage(port: number, host: string) {
56
67
  });
57
68
  },
58
69
  });
59
- log("INFO", `Stopped page serving on port ${port}`);
70
+ // Take over as the edge's target so the public URL shows this page.
71
+ try {
72
+ writeFileSync(SERVER_PORT_FILE(), String(stoppedServer.port));
73
+ } catch (e) {
74
+ log("WARN", `Failed to publish stopped-page port: ${e}`);
75
+ }
76
+ log("INFO", `Stopped page serving on ${host}:${stoppedServer.port}`);
60
77
  } catch (e) {
61
78
  log("WARN", `Failed to start stopped page: ${e}`);
62
79
  }