@hienlh/ppm 0.17.50 → 0.17.52
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/.rollout-mark +2 -0
- package/CHANGELOG.md +11 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{ai-resource-editor-x8J0omsj.js → ai-resource-editor-D12van6q.js} +1 -1
- package/dist/web/assets/{audio-preview-Bdf_SZHY.js → audio-preview-C3YJNKjD.js} +1 -1
- package/dist/web/assets/{chat-tab-CT6SP5D3.js → chat-tab-B6Elb3uK.js} +3 -3
- package/dist/web/assets/{code-editor-BgsJkCtK.js → code-editor-DM-ay_BF.js} +2 -2
- package/dist/web/assets/{conflict-editor-DdSe9RCG.js → conflict-editor-4rb0oS2i.js} +1 -1
- package/dist/web/assets/{csv-preview-DrUfLPRE.js → csv-preview-Bqfk0OeJ.js} +1 -1
- package/dist/web/assets/{database-viewer-1NLuRknj.js → database-viewer-CrBOZBE4.js} +1 -1
- package/dist/web/assets/{diff-viewer-DPn8QJXG.js → diff-viewer-BX02Fg5_.js} +1 -1
- package/dist/web/assets/{docx-preview-BUVtMDQF.js → docx-preview-rR_dLI4P.js} +1 -1
- package/dist/web/assets/{extension-webview-CEdJP2XP.js → extension-webview-D4rHB1h7.js} +1 -1
- package/dist/web/assets/{git-log-panel-ClLf6-as.js → git-log-panel-BryqAkz2.js} +1 -1
- package/dist/web/assets/{glide-data-grid-CBhv99Ii.js → glide-data-grid-CcGfgB3d.js} +1 -1
- package/dist/web/assets/{group-chat-tab-KTKtMy6L.js → group-chat-tab-rB8JuZIe.js} +1 -1
- package/dist/web/assets/{image-preview-DYyo-Twv.js → image-preview-i-jTDsjR.js} +1 -1
- package/dist/web/assets/{index-BiBAhdYZ.js → index-C4m82SWg.js} +22 -22
- package/dist/web/assets/{keybindings-store-C1yAVIlX.js → keybindings-store-C3dkxGGX.js} +1 -1
- package/dist/web/assets/{markdown-renderer-JNQd7xG0.js → markdown-renderer-DEXzjjQ8.js} +1 -1
- package/dist/web/assets/{markdown-renderer-CCBPLJC7.js → markdown-renderer-PbB8SlkU.js} +1 -1
- package/dist/web/assets/{notification-store-lQg17iIv.js → notification-store-6-9K7U26.js} +1 -1
- package/dist/web/assets/{pdf-preview-CWnXgjqc.js → pdf-preview-k7xI6wlU.js} +1 -1
- package/dist/web/assets/{postgres-viewer-BIXfNiGy.js → postgres-viewer-5zQgJlQz.js} +1 -1
- package/dist/web/assets/{settings-tab-CpltHPv5.js → settings-tab-DDNHT-XP.js} +1 -1
- package/dist/web/assets/{sql-query-editor-BDTzbvEM.js → sql-query-editor-CYLWqNpT.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-BkOrBWZP.js → sqlite-viewer-DANvopw7.js} +1 -1
- package/dist/web/assets/{system-monitor-tab-s0Jm9zah.js → system-monitor-tab-Cfs1hq4q.js} +1 -1
- package/dist/web/assets/{terminal-tab-D3S_kz4Y.js → terminal-tab-CTB-qo9d.js} +1 -1
- package/dist/web/assets/{tool-cards-DbhWrJ_o.js → tool-cards-BBr3Pix3.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-DKGvvHk1.js → use-monaco-theme-BJHYbrpI.js} +1 -1
- package/dist/web/assets/{video-preview-BCU0PfT1.js → video-preview-Dv1PD_Rj.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/docker-compose.test.yml +3 -0
- package/docs/lessons-learned.md +62 -0
- package/package.json +1 -1
- package/src/cli/commands/init.ts +2 -10
- package/src/providers/claude-agent-sdk.ts +2 -10
- 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/src/types/claude-models.ts +41 -0
- package/src/types/config.ts +3 -1
- package/src/web/components/settings/proxy-test-section.tsx +4 -8
|
@@ -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 {
|