z-clean 0.1.1
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/LICENSE +21 -0
- package/README.md +186 -0
- package/bin/zclean.js +272 -0
- package/package.json +12 -0
- package/src/config.js +148 -0
- package/src/detector/orphan.js +149 -0
- package/src/detector/patterns.js +148 -0
- package/src/detector/whitelist.js +178 -0
- package/src/installer/hook.js +136 -0
- package/src/installer/launchd.js +166 -0
- package/src/installer/systemd.js +162 -0
- package/src/installer/taskscheduler.js +102 -0
- package/src/killer.js +248 -0
- package/src/reporter.js +220 -0
- package/src/scanner.js +296 -0
package/src/reporter.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ANSI color codes — no external dependencies.
|
|
5
|
+
*/
|
|
6
|
+
const C = {
|
|
7
|
+
reset: '\x1b[0m',
|
|
8
|
+
bold: '\x1b[1m',
|
|
9
|
+
dim: '\x1b[2m',
|
|
10
|
+
red: '\x1b[31m',
|
|
11
|
+
green: '\x1b[32m',
|
|
12
|
+
yellow: '\x1b[33m',
|
|
13
|
+
blue: '\x1b[34m',
|
|
14
|
+
magenta: '\x1b[35m',
|
|
15
|
+
cyan: '\x1b[36m',
|
|
16
|
+
white: '\x1b[37m',
|
|
17
|
+
gray: '\x1b[90m',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Disable colors if NO_COLOR env is set or not a TTY
|
|
21
|
+
const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
|
|
22
|
+
|
|
23
|
+
function c(color, text) {
|
|
24
|
+
if (!useColor) return text;
|
|
25
|
+
return `${C[color]}${text}${C.reset}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function bold(text) {
|
|
29
|
+
if (!useColor) return text;
|
|
30
|
+
return `${C.bold}${text}${C.reset}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Format bytes into human-readable string.
|
|
35
|
+
*/
|
|
36
|
+
function formatBytes(bytes) {
|
|
37
|
+
if (bytes === 0) return '0 B';
|
|
38
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
39
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
40
|
+
const val = (bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0);
|
|
41
|
+
return `${val} ${units[i]}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Format milliseconds into human-readable duration.
|
|
46
|
+
*/
|
|
47
|
+
function formatDuration(ms) {
|
|
48
|
+
if (ms < 60000) return `${Math.floor(ms / 1000)}s`;
|
|
49
|
+
if (ms < 3600000) return `${Math.floor(ms / 60000)}m`;
|
|
50
|
+
if (ms < 86400000) {
|
|
51
|
+
const h = Math.floor(ms / 3600000);
|
|
52
|
+
const m = Math.floor((ms % 3600000) / 60000);
|
|
53
|
+
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
54
|
+
}
|
|
55
|
+
const d = Math.floor(ms / 86400000);
|
|
56
|
+
const h = Math.floor((ms % 86400000) / 3600000);
|
|
57
|
+
return h > 0 ? `${d}d ${h}h` : `${d}d`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Truncate a string to maxLen, adding ellipsis if needed.
|
|
62
|
+
*/
|
|
63
|
+
function truncate(str, maxLen = 80) {
|
|
64
|
+
if (str.length <= maxLen) return str;
|
|
65
|
+
return str.substring(0, maxLen - 3) + '...';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Report dry-run scan results.
|
|
70
|
+
*/
|
|
71
|
+
function reportDryRun(zombies) {
|
|
72
|
+
if (zombies.length === 0) {
|
|
73
|
+
console.log(c('green', ' No zombie processes found. System is clean.'));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log(bold(`\n Found ${c('yellow', String(zombies.length))} zombie process${zombies.length === 1 ? '' : 'es'}:\n`));
|
|
78
|
+
|
|
79
|
+
const totalMem = zombies.reduce((sum, z) => sum + z.mem, 0);
|
|
80
|
+
|
|
81
|
+
for (const z of zombies) {
|
|
82
|
+
console.log(` ${c('red', 'PID')} ${c('bold', String(z.pid).padStart(6))} ${c('cyan', z.name.padEnd(16))} ${c('yellow', formatBytes(z.mem).padStart(8))} ${c('gray', formatDuration(z.age).padStart(6))}`);
|
|
83
|
+
console.log(` ${c('gray', ' cmd:')} ${truncate(z.cmd, 72)}`);
|
|
84
|
+
console.log(` ${c('gray', ' why:')} ${z.reason}`);
|
|
85
|
+
console.log();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log(c('yellow', ` Total memory reclaimable: ${formatBytes(totalMem)}`));
|
|
89
|
+
console.log(c('gray', `\n Run ${c('white', 'zclean --yes')} to clean up these processes.\n`));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Report kill results.
|
|
94
|
+
*/
|
|
95
|
+
function reportKill(results) {
|
|
96
|
+
const { killed, failed, skipped } = results;
|
|
97
|
+
|
|
98
|
+
if (killed.length === 0 && failed.length === 0 && skipped.length === 0) {
|
|
99
|
+
console.log(c('green', ' No zombie processes to clean.'));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log();
|
|
104
|
+
|
|
105
|
+
if (killed.length > 0) {
|
|
106
|
+
const totalMem = killed.reduce((sum, p) => sum + p.mem, 0);
|
|
107
|
+
console.log(c('green', ` Killed ${killed.length} zombie process${killed.length === 1 ? '' : 'es'}:`));
|
|
108
|
+
for (const p of killed) {
|
|
109
|
+
console.log(` ${c('green', 'KILLED')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} ${formatBytes(p.mem).padStart(8)}`);
|
|
110
|
+
}
|
|
111
|
+
console.log(c('green', `\n Memory freed: ${formatBytes(totalMem)}`));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (skipped.length > 0) {
|
|
115
|
+
console.log(c('yellow', `\n Skipped ${skipped.length} (re-verification failed):`));
|
|
116
|
+
for (const p of skipped) {
|
|
117
|
+
console.log(` ${c('yellow', 'SKIP')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} reason: ${p.skipReason}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (failed.length > 0) {
|
|
122
|
+
console.log(c('red', `\n Failed to kill ${failed.length}:`));
|
|
123
|
+
for (const p of failed) {
|
|
124
|
+
console.log(` ${c('red', 'FAIL')} PID ${String(p.pid).padStart(6)} ${p.name.padEnd(16)} error: ${p.error}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
console.log();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Report current status (for `zclean status`).
|
|
133
|
+
*/
|
|
134
|
+
function reportStatus(zombies, logs) {
|
|
135
|
+
console.log(bold('\n zclean status\n'));
|
|
136
|
+
|
|
137
|
+
// Current zombies
|
|
138
|
+
if (zombies.length === 0) {
|
|
139
|
+
console.log(c('green', ' Current zombies: 0'));
|
|
140
|
+
} else {
|
|
141
|
+
console.log(c('yellow', ` Current zombies: ${zombies.length}`));
|
|
142
|
+
const totalMem = zombies.reduce((sum, z) => sum + z.mem, 0);
|
|
143
|
+
console.log(c('yellow', ` Memory held: ${formatBytes(totalMem)}`));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Last cleanup
|
|
147
|
+
const lastCleanup = logs.filter((l) => l.action === 'cleanup-summary').pop();
|
|
148
|
+
if (lastCleanup) {
|
|
149
|
+
console.log(` Last cleanup: ${c('gray', lastCleanup.timestamp)}`);
|
|
150
|
+
console.log(` Killed: ${lastCleanup.killed}`);
|
|
151
|
+
console.log(` Memory freed: ${formatBytes(lastCleanup.totalMemFreed || 0)}`);
|
|
152
|
+
} else {
|
|
153
|
+
console.log(c('gray', ' Last cleanup: never'));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Report recent logs (for `zclean logs`).
|
|
161
|
+
*/
|
|
162
|
+
function reportLogs(logs) {
|
|
163
|
+
if (logs.length === 0) {
|
|
164
|
+
console.log(c('gray', ' No cleanup history yet.\n'));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(bold('\n Recent cleanup history\n'));
|
|
169
|
+
|
|
170
|
+
for (const entry of logs.slice(-20)) {
|
|
171
|
+
const time = c('gray', entry.timestamp.replace('T', ' ').substring(0, 19));
|
|
172
|
+
|
|
173
|
+
switch (entry.action) {
|
|
174
|
+
case 'kill':
|
|
175
|
+
console.log(` ${time} ${c('green', 'KILL')} PID ${String(entry.pid).padStart(6)} ${(entry.name || '').padEnd(16)} ${formatBytes(entry.memFreed || 0)}`);
|
|
176
|
+
break;
|
|
177
|
+
case 'kill-failed':
|
|
178
|
+
console.log(` ${time} ${c('red', 'FAIL')} PID ${String(entry.pid).padStart(6)} ${(entry.name || '').padEnd(16)} ${entry.error || ''}`);
|
|
179
|
+
break;
|
|
180
|
+
case 'cleanup-summary':
|
|
181
|
+
console.log(` ${time} ${c('cyan', 'DONE')} killed:${entry.killed} failed:${entry.failed} skipped:${entry.skipped} freed:${formatBytes(entry.totalMemFreed || 0)}`);
|
|
182
|
+
break;
|
|
183
|
+
default:
|
|
184
|
+
console.log(` ${time} ${c('gray', entry.action)}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
console.log();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Report current config (for `zclean config`).
|
|
193
|
+
*/
|
|
194
|
+
function reportConfig(config, configPath) {
|
|
195
|
+
console.log(bold('\n zclean config\n'));
|
|
196
|
+
console.log(` Config file: ${c('gray', configPath)}`);
|
|
197
|
+
console.log();
|
|
198
|
+
|
|
199
|
+
for (const [key, value] of Object.entries(config)) {
|
|
200
|
+
const displayValue = Array.isArray(value)
|
|
201
|
+
? (value.length === 0 ? '[]' : JSON.stringify(value))
|
|
202
|
+
: String(value);
|
|
203
|
+
console.log(` ${c('cyan', key.padEnd(20))} ${displayValue}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
console.log();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
reportDryRun,
|
|
211
|
+
reportKill,
|
|
212
|
+
reportStatus,
|
|
213
|
+
reportLogs,
|
|
214
|
+
reportConfig,
|
|
215
|
+
formatBytes,
|
|
216
|
+
formatDuration,
|
|
217
|
+
C,
|
|
218
|
+
c,
|
|
219
|
+
bold,
|
|
220
|
+
};
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const { matchPattern } = require('./detector/patterns');
|
|
6
|
+
const { checkOrphan, isInTerminalMultiplexer } = require('./detector/orphan');
|
|
7
|
+
const { isWhitelisted } = require('./detector/whitelist');
|
|
8
|
+
const { parseDuration, parseMemory } = require('./config');
|
|
9
|
+
|
|
10
|
+
const platform = os.platform();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scan for zombie/orphan processes left by AI coding tools.
|
|
14
|
+
*
|
|
15
|
+
* Returns an array of process objects:
|
|
16
|
+
* { pid, name, cmd, ppid, mem, age, startTime, reason, pattern }
|
|
17
|
+
*
|
|
18
|
+
* @param {object} config — loaded zclean config
|
|
19
|
+
* @param {object} opts — { sessionPid?: number } for session-aware filtering
|
|
20
|
+
*/
|
|
21
|
+
function scan(config, opts = {}) {
|
|
22
|
+
const processes = listProcesses();
|
|
23
|
+
const zombies = [];
|
|
24
|
+
|
|
25
|
+
for (const proc of processes) {
|
|
26
|
+
// Match against known AI tool patterns
|
|
27
|
+
const pattern = matchPattern(proc.cmd);
|
|
28
|
+
if (!pattern) continue;
|
|
29
|
+
|
|
30
|
+
// Check orphan status
|
|
31
|
+
const orphanResult = checkOrphan(proc.pid);
|
|
32
|
+
proc.ppid = orphanResult.ppid;
|
|
33
|
+
|
|
34
|
+
// If pattern requires orphan status and process isn't orphaned, skip
|
|
35
|
+
if (pattern.orphanOnly && !orphanResult.isOrphan) continue;
|
|
36
|
+
|
|
37
|
+
// Skip processes in tmux/screen sessions (they're likely intentional)
|
|
38
|
+
if (isInTerminalMultiplexer(proc.pid)) continue;
|
|
39
|
+
|
|
40
|
+
// Check whitelist
|
|
41
|
+
const whitelistResult = isWhitelisted(proc, config);
|
|
42
|
+
if (whitelistResult.protected) continue;
|
|
43
|
+
|
|
44
|
+
// Check maxOrphanAge if defined on the pattern
|
|
45
|
+
if (pattern.maxOrphanAge) {
|
|
46
|
+
const maxAge = parseDuration(pattern.maxOrphanAge);
|
|
47
|
+
if (maxAge && proc.age < maxAge) continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check memory threshold for node-ai-path pattern
|
|
51
|
+
if (pattern.memThreshold) {
|
|
52
|
+
const threshold = parseMemory(pattern.memThreshold);
|
|
53
|
+
const configThreshold = parseMemory(config.memoryThreshold);
|
|
54
|
+
// Use whichever is set — pattern threshold is the gate, but either can trigger
|
|
55
|
+
if (threshold && proc.mem < threshold) {
|
|
56
|
+
// Also check if age exceeds maxOrphanAge
|
|
57
|
+
if (pattern.maxOrphanAge) {
|
|
58
|
+
const maxAge = parseDuration(pattern.maxOrphanAge);
|
|
59
|
+
if (maxAge && proc.age < maxAge) continue;
|
|
60
|
+
} else {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Session affinity: if --session-pid was provided, prefer processes
|
|
67
|
+
// that were children of that session
|
|
68
|
+
if (opts.sessionPid) {
|
|
69
|
+
// Still include non-session orphans, but note affinity
|
|
70
|
+
proc.sessionRelated = isSessionRelated(proc.pid, opts.sessionPid);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Build reason string
|
|
74
|
+
const reasons = [];
|
|
75
|
+
reasons.push(`pattern:${pattern.name}`);
|
|
76
|
+
if (orphanResult.isOrphan) reasons.push(`orphan:${orphanResult.reason}`);
|
|
77
|
+
if (proc.age > parseDuration(config.maxAge || '24h')) reasons.push('age-exceeded');
|
|
78
|
+
if (parseMemory(config.memoryThreshold) && proc.mem > parseMemory(config.memoryThreshold)) {
|
|
79
|
+
reasons.push('memory-exceeded');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
zombies.push({
|
|
83
|
+
pid: proc.pid,
|
|
84
|
+
name: pattern.name,
|
|
85
|
+
cmd: proc.cmd,
|
|
86
|
+
ppid: proc.ppid,
|
|
87
|
+
mem: proc.mem,
|
|
88
|
+
age: proc.age,
|
|
89
|
+
startTime: proc.startTime,
|
|
90
|
+
reason: reasons.join(', '),
|
|
91
|
+
pattern: pattern.name,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return zombies;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* List all processes with their details.
|
|
100
|
+
* Cross-platform: macOS/Linux use `ps`, Windows uses `wmic`.
|
|
101
|
+
*
|
|
102
|
+
* Returns array of { pid, cmd, mem, age, startTime }
|
|
103
|
+
*/
|
|
104
|
+
function listProcesses() {
|
|
105
|
+
if (platform === 'win32') {
|
|
106
|
+
return listProcessesWindows();
|
|
107
|
+
}
|
|
108
|
+
return listProcessesUnix();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Unix process listing via `ps aux`.
|
|
113
|
+
*/
|
|
114
|
+
function listProcessesUnix() {
|
|
115
|
+
let output;
|
|
116
|
+
try {
|
|
117
|
+
// ps aux with etime for age calculation
|
|
118
|
+
// Columns: PID, RSS (KB), ELAPSED, STARTED, COMMAND
|
|
119
|
+
output = execSync('ps -eo pid=,rss=,etime=,lstart=,command=', {
|
|
120
|
+
encoding: 'utf-8',
|
|
121
|
+
timeout: 10000,
|
|
122
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
123
|
+
});
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const processes = [];
|
|
129
|
+
const lines = output.trim().split('\n');
|
|
130
|
+
const myPid = process.pid;
|
|
131
|
+
|
|
132
|
+
for (const line of lines) {
|
|
133
|
+
const trimmed = line.trim();
|
|
134
|
+
if (!trimmed) continue;
|
|
135
|
+
|
|
136
|
+
// Parse the fixed-width fields
|
|
137
|
+
// Format: " PID RSS ELAPSED LSTART COMMAND"
|
|
138
|
+
const match = trimmed.match(
|
|
139
|
+
/^\s*(\d+)\s+(\d+)\s+([\d:.-]+)\s+\w+\s+(\w+\s+\d+\s+[\d:]+\s+\d+)\s+(.+)$/
|
|
140
|
+
);
|
|
141
|
+
if (!match) continue;
|
|
142
|
+
|
|
143
|
+
const pid = parseInt(match[1], 10);
|
|
144
|
+
const rssKB = parseInt(match[2], 10);
|
|
145
|
+
const elapsed = match[3];
|
|
146
|
+
const lstart = match[4];
|
|
147
|
+
const cmd = match[5];
|
|
148
|
+
|
|
149
|
+
// Skip our own process
|
|
150
|
+
if (pid === myPid) continue;
|
|
151
|
+
|
|
152
|
+
// Parse elapsed time (format: [[DD-]HH:]MM:SS)
|
|
153
|
+
const ageMs = parseElapsed(elapsed);
|
|
154
|
+
|
|
155
|
+
// Parse start time
|
|
156
|
+
let startTime = null;
|
|
157
|
+
try {
|
|
158
|
+
startTime = new Date(lstart).toISOString();
|
|
159
|
+
} catch {
|
|
160
|
+
// Ignore parse errors
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
processes.push({
|
|
164
|
+
pid,
|
|
165
|
+
cmd,
|
|
166
|
+
mem: rssKB * 1024, // Convert KB to bytes
|
|
167
|
+
age: ageMs,
|
|
168
|
+
startTime,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return processes;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Windows process listing via wmic.
|
|
177
|
+
*/
|
|
178
|
+
function listProcessesWindows() {
|
|
179
|
+
let output;
|
|
180
|
+
try {
|
|
181
|
+
output = execSync(
|
|
182
|
+
'wmic process get ProcessId,CommandLine,WorkingSetSize,CreationDate /format:csv',
|
|
183
|
+
{ encoding: 'utf-8', timeout: 15000, maxBuffer: 10 * 1024 * 1024 }
|
|
184
|
+
);
|
|
185
|
+
} catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const processes = [];
|
|
190
|
+
const lines = output.trim().split('\n');
|
|
191
|
+
const myPid = process.pid;
|
|
192
|
+
|
|
193
|
+
for (const line of lines) {
|
|
194
|
+
const parts = line.trim().split(',');
|
|
195
|
+
if (parts.length < 5) continue;
|
|
196
|
+
|
|
197
|
+
// CSV format: Node, CommandLine, CreationDate, ProcessId, WorkingSetSize
|
|
198
|
+
const cmd = parts[1];
|
|
199
|
+
const creationDate = parts[2];
|
|
200
|
+
const pid = parseInt(parts[3], 10);
|
|
201
|
+
const workingSet = parseInt(parts[4], 10);
|
|
202
|
+
|
|
203
|
+
if (isNaN(pid) || pid === myPid || !cmd) continue;
|
|
204
|
+
|
|
205
|
+
// Parse WMI datetime: YYYYMMDDHHMMSS.MMMMMM+UUU
|
|
206
|
+
let ageMs = 0;
|
|
207
|
+
let startTime = null;
|
|
208
|
+
if (creationDate) {
|
|
209
|
+
try {
|
|
210
|
+
const year = creationDate.substring(0, 4);
|
|
211
|
+
const month = creationDate.substring(4, 6);
|
|
212
|
+
const day = creationDate.substring(6, 8);
|
|
213
|
+
const hours = creationDate.substring(8, 10);
|
|
214
|
+
const minutes = creationDate.substring(10, 12);
|
|
215
|
+
const seconds = creationDate.substring(12, 14);
|
|
216
|
+
const dt = new Date(`${year}-${month}-${day}T${hours}:${minutes}:${seconds}`);
|
|
217
|
+
startTime = dt.toISOString();
|
|
218
|
+
ageMs = Date.now() - dt.getTime();
|
|
219
|
+
} catch {
|
|
220
|
+
// Ignore
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
processes.push({
|
|
225
|
+
pid,
|
|
226
|
+
cmd,
|
|
227
|
+
mem: workingSet || 0,
|
|
228
|
+
age: ageMs,
|
|
229
|
+
startTime,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return processes;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Parse ps elapsed time format: [[DD-]HH:]MM:SS or DD-HH:MM:SS
|
|
238
|
+
* Returns milliseconds.
|
|
239
|
+
*/
|
|
240
|
+
function parseElapsed(elapsed) {
|
|
241
|
+
if (!elapsed) return 0;
|
|
242
|
+
|
|
243
|
+
let days = 0;
|
|
244
|
+
let rest = elapsed.trim();
|
|
245
|
+
|
|
246
|
+
// Check for "DD-" prefix
|
|
247
|
+
const dayMatch = rest.match(/^(\d+)-(.+)$/);
|
|
248
|
+
if (dayMatch) {
|
|
249
|
+
days = parseInt(dayMatch[1], 10);
|
|
250
|
+
rest = dayMatch[2];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const parts = rest.split(':').map((p) => parseInt(p, 10));
|
|
254
|
+
|
|
255
|
+
let hours = 0, minutes = 0, seconds = 0;
|
|
256
|
+
if (parts.length === 3) {
|
|
257
|
+
[hours, minutes, seconds] = parts;
|
|
258
|
+
} else if (parts.length === 2) {
|
|
259
|
+
[minutes, seconds] = parts;
|
|
260
|
+
} else if (parts.length === 1) {
|
|
261
|
+
[seconds] = parts;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return ((days * 24 + hours) * 3600 + minutes * 60 + seconds) * 1000;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Check if a process was a descendant of a given session PID.
|
|
269
|
+
* Used for session-affinity cleanup.
|
|
270
|
+
*/
|
|
271
|
+
function isSessionRelated(pid, sessionPid) {
|
|
272
|
+
if (platform === 'win32') return false;
|
|
273
|
+
|
|
274
|
+
const visited = new Set();
|
|
275
|
+
let currentPid = pid;
|
|
276
|
+
|
|
277
|
+
while (currentPid > 1 && !visited.has(currentPid)) {
|
|
278
|
+
visited.add(currentPid);
|
|
279
|
+
if (currentPid === sessionPid) return true;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const ppidStr = execSync(`ps -o ppid= -p ${currentPid}`, {
|
|
283
|
+
encoding: 'utf-8',
|
|
284
|
+
timeout: 3000,
|
|
285
|
+
}).trim();
|
|
286
|
+
currentPid = parseInt(ppidStr, 10);
|
|
287
|
+
if (isNaN(currentPid)) break;
|
|
288
|
+
} catch {
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = { scan, listProcesses, parseElapsed };
|