z-clean 0.1.1 → 0.3.0

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,338 @@
1
+ 'use strict';
2
+
3
+ const { execSync } = require('child_process');
4
+ const os = require('os');
5
+ const { readCIMProcesses, readWMICProcesses } = require('./windows-processes');
6
+
7
+ const platform = os.platform();
8
+
9
+ /**
10
+ * In-memory process tree for O(1) PID lookup and O(depth) ancestor traversal.
11
+ * Built from one platform enumeration call sequence — zero execSync during queries.
12
+ *
13
+ * Usage:
14
+ * const tree = ProcessTree.build(); // platform auto-detect
15
+ * tree.isOrphan(pid) // no execSync
16
+ * tree.hasAncestorMatching(pid, testFn) // no execSync
17
+ */
18
+ class ProcessTree {
19
+ /**
20
+ * @param {Array<{pid: number, ppid: number, cmd: string, mem: number, age: number, startTime: string|null}>} processes
21
+ */
22
+ constructor(processes, diagnostics = {}) {
23
+ this.warnings = Array.isArray(diagnostics.warnings) ? diagnostics.warnings : [];
24
+ this.errors = Array.isArray(diagnostics.errors) ? diagnostics.errors : [];
25
+ this.platform = diagnostics.platform || platform;
26
+
27
+ /** @type {Map<number, object>} */
28
+ this.byPid = new Map();
29
+ /** @type {Map<number, number[]>} pid → [childPids] */
30
+ this.childrenMap = new Map();
31
+
32
+ for (const proc of processes) {
33
+ this.byPid.set(proc.pid, proc);
34
+ if (!this.childrenMap.has(proc.ppid)) {
35
+ this.childrenMap.set(proc.ppid, []);
36
+ }
37
+ this.childrenMap.get(proc.ppid).push(proc.pid);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * O(1) lookup by PID.
43
+ * @returns {object|null}
44
+ */
45
+ get(pid) {
46
+ return this.byPid.get(pid) || null;
47
+ }
48
+
49
+ /**
50
+ * Parent process info, or null if PID not in tree or parent unknown.
51
+ * @returns {object|null}
52
+ */
53
+ parent(pid) {
54
+ const proc = this.byPid.get(pid);
55
+ if (!proc) return null;
56
+ return this.byPid.get(proc.ppid) || null;
57
+ }
58
+
59
+ /**
60
+ * Array of direct child process infos.
61
+ * @returns {object[]}
62
+ */
63
+ children(pid) {
64
+ const childPids = this.childrenMap.get(pid) || [];
65
+ return childPids.map((cpid) => this.byPid.get(cpid)).filter(Boolean);
66
+ }
67
+
68
+ /**
69
+ * Ancestor chain starting from the direct parent, root last.
70
+ * Stops at PID 1 or when the parent is not in the tree.
71
+ * @returns {object[]}
72
+ */
73
+ ancestors(pid) {
74
+ const result = [];
75
+ const visited = new Set();
76
+ let current = this.byPid.get(pid);
77
+
78
+ while (current) {
79
+ if (visited.has(current.pid)) break;
80
+ visited.add(current.pid);
81
+ const par = this.byPid.get(current.ppid);
82
+ if (!par) break;
83
+ result.push(par);
84
+ current = par;
85
+ }
86
+
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * Determine orphan status from in-memory tree — no execSync.
92
+ *
93
+ * @returns {{ isOrphan: boolean, ppid: number|null, reason: string }}
94
+ */
95
+ isOrphan(pid) {
96
+ const proc = this.byPid.get(pid);
97
+ if (!proc) return { isOrphan: false, ppid: null, reason: 'not-in-tree' };
98
+
99
+ const { ppid } = proc;
100
+
101
+ if (this.platform === 'win32') {
102
+ if (!this.byPid.has(ppid)) {
103
+ return { isOrphan: true, ppid, reason: 'parent-gone' };
104
+ }
105
+ return { isOrphan: false, ppid, reason: 'has-parent' };
106
+ }
107
+
108
+ // macOS: PPID 1 = reparented to launchd
109
+ if (this.platform === 'darwin' && ppid === 1) {
110
+ return { isOrphan: true, ppid: 1, reason: 'reparented-to-launchd' };
111
+ }
112
+
113
+ // Linux: PPID 1 = reparented to init
114
+ if (this.platform === 'linux' && ppid === 1) {
115
+ return { isOrphan: true, ppid: 1, reason: 'reparented-to-init' };
116
+ }
117
+
118
+ // Linux: parent is systemd --user (user session slice)
119
+ if (this.platform === 'linux' && ppid > 1) {
120
+ const parentProc = this.byPid.get(ppid);
121
+ if (parentProc) {
122
+ // Extract process name (basename of first token)
123
+ const comm = parentProc.cmd.split(/\s+/)[0].split('/').pop();
124
+ if (comm === 'systemd') {
125
+ return { isOrphan: true, ppid, reason: 'reparented-to-systemd-user' };
126
+ }
127
+ }
128
+ }
129
+
130
+ // Has living parent in the tree
131
+ if (this.byPid.has(ppid)) {
132
+ return { isOrphan: false, ppid, reason: 'has-parent' };
133
+ }
134
+
135
+ // Parent not in tree and not PID 1 — likely died
136
+ return { isOrphan: true, ppid, reason: 'parent-gone' };
137
+ }
138
+
139
+ /**
140
+ * Walk ancestor chain, return true if any ancestor passes testFn.
141
+ * Cycle-safe via visited Set. Stops at PID 1 or when parent not in tree.
142
+ *
143
+ * @param {number} pid
144
+ * @param {(proc: object) => boolean} testFn
145
+ * @returns {boolean}
146
+ */
147
+ hasAncestorMatching(pid, testFn) {
148
+ const visited = new Set();
149
+ let current = this.byPid.get(pid);
150
+
151
+ while (current) {
152
+ if (visited.has(current.pid)) break; // cycle protection
153
+ visited.add(current.pid);
154
+
155
+ const parent = this.byPid.get(current.ppid);
156
+ if (!parent || parent.pid === 1) break;
157
+
158
+ if (testFn(parent)) return true;
159
+ current = parent;
160
+ }
161
+
162
+ return false;
163
+ }
164
+
165
+ /**
166
+ * Unix: build ProcessTree from a single `ps` call.
167
+ * Format: pid= ppid= rss= etime= lstart= command=
168
+ *
169
+ * @returns {ProcessTree}
170
+ */
171
+ static fromPS(options = {}) {
172
+ const runtime = runtimeOptions(options);
173
+ let output;
174
+ try {
175
+ // LC_ALL=C forces English date output in lstart= regardless of system locale
176
+ output = runtime.execSync('LC_ALL=C ps -eo pid=,ppid=,rss=,etime=,lstart=,command=', {
177
+ encoding: 'utf-8',
178
+ timeout: 10000,
179
+ maxBuffer: 10 * 1024 * 1024,
180
+ });
181
+ } catch (err) {
182
+ return new ProcessTree([], {
183
+ platform: runtime.platform,
184
+ errors: [providerDiagnostic('ps', 'process-enumeration-provider-failed', err)],
185
+ });
186
+ }
187
+
188
+ const processes = [];
189
+ const myPid = runtime.currentPid;
190
+
191
+ for (const line of output.trim().split('\n')) {
192
+ const trimmed = line.trim();
193
+ if (!trimmed) continue;
194
+
195
+ // Format: PID PPID RSS ELAPSED [DAY ]MON DD HH:MM:SS YEAR COMMAND
196
+ // Example: " 123 456 2048 01:23:45 Mon Jan 1 12:00:00 2024 node server.js"
197
+ const match = trimmed.match(
198
+ /^\s*(\d+)\s+(\d+)\s+(\d+)\s+([\d:.-]+)\s+\w+\s+(\w+\s+\d+\s+[\d:]+\s+\d+)\s+(.+)$/
199
+ );
200
+ if (!match) continue;
201
+
202
+ const pid = parseInt(match[1], 10);
203
+ if (pid === myPid) continue;
204
+
205
+ const ppid = parseInt(match[2], 10);
206
+ const rssKB = parseInt(match[3], 10);
207
+ const elapsed = match[4];
208
+ const lstart = match[5];
209
+ const cmd = match[6];
210
+
211
+ let startTime = null;
212
+ try { startTime = new Date(lstart).toISOString(); } catch { /* ignore */ }
213
+
214
+ processes.push({
215
+ pid,
216
+ ppid,
217
+ cmd,
218
+ mem: rssKB * 1024, // KB → bytes
219
+ age: parseElapsed(elapsed),
220
+ startTime,
221
+ });
222
+ }
223
+
224
+ return new ProcessTree(processes, { platform: runtime.platform });
225
+ }
226
+
227
+ static fromWMIC(options = {}) {
228
+ const runtime = runtimeOptions({ ...options, platform: 'win32' });
229
+ const result = readWMICProcesses(runtime);
230
+ return new ProcessTree(result.processes, {
231
+ platform: runtime.platform,
232
+ warnings: result.warnings,
233
+ });
234
+ }
235
+
236
+ static fromCIM(options = {}) {
237
+ const runtime = runtimeOptions({ ...options, platform: 'win32' });
238
+ const result = readCIMProcesses(runtime);
239
+ return new ProcessTree(result.processes, {
240
+ platform: runtime.platform,
241
+ warnings: result.warnings,
242
+ });
243
+ }
244
+
245
+ static fromWindows(options = {}) {
246
+ const runtime = runtimeOptions({ ...options, platform: 'win32' });
247
+ const wmic = readWMICProcesses(runtime);
248
+ if (wmic.processes.length > 0) {
249
+ return new ProcessTree(wmic.processes, { platform: runtime.platform });
250
+ }
251
+
252
+ const warnings = [...wmic.warnings];
253
+ const cim = readCIMProcesses(runtime);
254
+ if (cim.processes.length > 0) {
255
+ return new ProcessTree(cim.processes, {
256
+ platform: runtime.platform,
257
+ warnings: warnings.concat(cim.warnings),
258
+ });
259
+ }
260
+
261
+ warnings.push(...cim.warnings);
262
+ return new ProcessTree([], {
263
+ platform: runtime.platform,
264
+ warnings,
265
+ errors: [{
266
+ code: 'process-enumeration-failed',
267
+ platform: 'win32',
268
+ providers: ['cim', 'wmic'],
269
+ message: 'Windows process enumeration failed for all providers.',
270
+ }],
271
+ });
272
+ }
273
+
274
+ /**
275
+ * Platform-aware factory. Use this from scanner/orphan/whitelist.
276
+ * Calls fromWindows() on Windows, fromPS() everywhere else.
277
+ *
278
+ * @returns {ProcessTree}
279
+ */
280
+ static build(options = {}) {
281
+ const runtime = runtimeOptions(options);
282
+ return runtime.platform === 'win32'
283
+ ? ProcessTree.fromWindows(runtime)
284
+ : ProcessTree.fromPS(runtime);
285
+ }
286
+ }
287
+
288
+ function runtimeOptions(options = {}) {
289
+ return {
290
+ execSync: options.execSync || execSync,
291
+ platform: options.platform || platform,
292
+ currentPid: options.currentPid || process.pid,
293
+ now: options.now || Date.now(),
294
+ };
295
+ }
296
+
297
+ function providerDiagnostic(provider, code, err) {
298
+ return {
299
+ code,
300
+ provider,
301
+ message: err instanceof Error ? err.message : String(err || ''),
302
+ };
303
+ }
304
+
305
+ /**
306
+ * Parse ps elapsed time format: [[DD-]HH:]MM:SS
307
+ * Moved here from scanner.js to co-locate with ProcessTree.
308
+ *
309
+ * @param {string} elapsed
310
+ * @returns {number} milliseconds
311
+ */
312
+ function parseElapsed(elapsed) {
313
+ if (!elapsed) return 0;
314
+
315
+ let days = 0;
316
+ let rest = elapsed.trim();
317
+
318
+ const dayMatch = rest.match(/^(\d+)-(.+)$/);
319
+ if (dayMatch) {
320
+ days = parseInt(dayMatch[1], 10);
321
+ rest = dayMatch[2];
322
+ }
323
+
324
+ const parts = rest.split(':').map((p) => parseInt(p, 10));
325
+
326
+ let hours = 0, minutes = 0, seconds = 0;
327
+ if (parts.length === 3) {
328
+ [hours, minutes, seconds] = parts;
329
+ } else if (parts.length === 2) {
330
+ [minutes, seconds] = parts;
331
+ } else if (parts.length === 1) {
332
+ [seconds] = parts;
333
+ }
334
+
335
+ return ((days * 24 + hours) * 3600 + minutes * 60 + seconds) * 1000;
336
+ }
337
+
338
+ module.exports = { ProcessTree, parseElapsed };
package/src/reporter.js CHANGED
@@ -65,10 +65,54 @@ function truncate(str, maxLen = 80) {
65
65
  return str.substring(0, maxLen - 3) + '...';
66
66
  }
67
67
 
68
+ function getDiagnostics(result) {
69
+ return {
70
+ warnings: Array.isArray(result?.warnings) ? result.warnings : [],
71
+ errors: Array.isArray(result?.errors) ? result.errors : [],
72
+ };
73
+ }
74
+
75
+ function formatDiagnostic(item) {
76
+ if (typeof item === 'string') return item;
77
+ const code = item.code ? `${item.code}: ` : '';
78
+ const providers = Array.isArray(item.providers) && item.providers.length > 0
79
+ ? ` (providers: ${item.providers.join(', ')})`
80
+ : '';
81
+ return `${code}${item.message || 'process scan diagnostic'}${providers}`;
82
+ }
83
+
84
+ function reportScanDiagnostics(result) {
85
+ const { warnings, errors } = getDiagnostics(result);
86
+
87
+ if (warnings.length > 0) {
88
+ console.log(c('yellow', ' Scan warnings:'));
89
+ for (const warning of warnings) {
90
+ console.log(` ${formatDiagnostic(warning)}`);
91
+ }
92
+ console.log();
93
+ }
94
+
95
+ if (errors.length > 0) {
96
+ console.log(c('red', ' Process enumeration failed. Scan results are incomplete.'));
97
+ for (const error of errors) {
98
+ console.log(` ${formatDiagnostic(error)}`);
99
+ }
100
+ console.log();
101
+ return true;
102
+ }
103
+
104
+ return false;
105
+ }
106
+
68
107
  /**
69
108
  * Report dry-run scan results.
70
109
  */
71
110
  function reportDryRun(zombies) {
111
+ if (reportScanDiagnostics(zombies)) {
112
+ console.log(c('yellow', ' Cleanup skipped because zclean could not inspect the process list safely.\n'));
113
+ return;
114
+ }
115
+
72
116
  if (zombies.length === 0) {
73
117
  console.log(c('green', ' No zombie processes found. System is clean.'));
74
118
  return;
@@ -93,7 +137,7 @@ function reportDryRun(zombies) {
93
137
  * Report kill results.
94
138
  */
95
139
  function reportKill(results) {
96
- const { killed, failed, skipped } = results;
140
+ const { killed, failed, skipped, warning } = results;
97
141
 
98
142
  if (killed.length === 0 && failed.length === 0 && skipped.length === 0) {
99
143
  console.log(c('green', ' No zombie processes to clean.'));
@@ -102,6 +146,11 @@ function reportKill(results) {
102
146
 
103
147
  console.log();
104
148
 
149
+ if (warning) {
150
+ console.log(c('yellow', ` ${warning}`));
151
+ console.log();
152
+ }
153
+
105
154
  if (killed.length > 0) {
106
155
  const totalMem = killed.reduce((sum, p) => sum + p.mem, 0);
107
156
  console.log(c('green', ` Killed ${killed.length} zombie process${killed.length === 1 ? '' : 'es'}:`));
@@ -109,6 +158,13 @@ function reportKill(results) {
109
158
  console.log(` ${c('green', 'KILLED')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} ${formatBytes(p.mem).padStart(8)}`);
110
159
  }
111
160
  console.log(c('green', `\n Memory freed: ${formatBytes(totalMem)}`));
161
+
162
+ // Show cumulative stats if available
163
+ if (results.cumulative) {
164
+ const s = results.cumulative;
165
+ console.log(c('gray', ` This week: ${s.weekKilled} cleaned, ${formatBytes(s.weekMemFreed)} freed`));
166
+ console.log(c('gray', ` All time: ${s.totalKilled} cleaned, ${formatBytes(s.totalMemFreed)} freed`));
167
+ }
112
168
  }
113
169
 
114
170
  if (skipped.length > 0) {
@@ -135,7 +191,9 @@ function reportStatus(zombies, logs) {
135
191
  console.log(bold('\n zclean status\n'));
136
192
 
137
193
  // Current zombies
138
- if (zombies.length === 0) {
194
+ if (reportScanDiagnostics(zombies)) {
195
+ console.log(c('red', ' Current zombies: unknown'));
196
+ } else if (zombies.length === 0) {
139
197
  console.log(c('green', ' Current zombies: 0'));
140
198
  } else {
141
199
  console.log(c('yellow', ` Current zombies: ${zombies.length}`));
@@ -212,6 +270,7 @@ module.exports = {
212
270
  reportStatus,
213
271
  reportLogs,
214
272
  reportConfig,
273
+ reportScanDiagnostics,
215
274
  formatBytes,
216
275
  formatDuration,
217
276
  C,