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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const PLIST_NAME = 'com.zclean.hourly';
|
|
9
|
+
const PLIST_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
10
|
+
const PLIST_PATH = path.join(PLIST_DIR, `${PLIST_NAME}.plist`);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the full path to the zclean binary.
|
|
14
|
+
* Tries: npx global, npm global, local install.
|
|
15
|
+
*/
|
|
16
|
+
function resolveZcleanBin() {
|
|
17
|
+
// If installed globally via npm
|
|
18
|
+
try {
|
|
19
|
+
const npmBin = execSync('npm bin -g', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
20
|
+
const globalPath = path.join(npmBin, 'zclean');
|
|
21
|
+
if (fs.existsSync(globalPath)) return globalPath;
|
|
22
|
+
} catch { /* ignore */ }
|
|
23
|
+
|
|
24
|
+
// Check common locations
|
|
25
|
+
const candidates = [
|
|
26
|
+
path.join(os.homedir(), '.local', 'bin', 'zclean'),
|
|
27
|
+
'/usr/local/bin/zclean',
|
|
28
|
+
path.join(os.homedir(), 'node_modules', '.bin', 'zclean'),
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
for (const candidate of candidates) {
|
|
32
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fallback to npx
|
|
36
|
+
return 'npx zclean';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generate the launchd plist XML.
|
|
41
|
+
*/
|
|
42
|
+
function generatePlist(binPath) {
|
|
43
|
+
const parts = binPath.split(' ');
|
|
44
|
+
const programArgs = parts
|
|
45
|
+
.concat(['--yes'])
|
|
46
|
+
.map((arg) => ` <string>${arg}</string>`)
|
|
47
|
+
.join('\n');
|
|
48
|
+
|
|
49
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
50
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
51
|
+
<plist version="1.0">
|
|
52
|
+
<dict>
|
|
53
|
+
<key>Label</key>
|
|
54
|
+
<string>${PLIST_NAME}</string>
|
|
55
|
+
|
|
56
|
+
<key>ProgramArguments</key>
|
|
57
|
+
<array>
|
|
58
|
+
${programArgs}
|
|
59
|
+
</array>
|
|
60
|
+
|
|
61
|
+
<key>StartInterval</key>
|
|
62
|
+
<integer>3600</integer>
|
|
63
|
+
|
|
64
|
+
<key>RunAtLoad</key>
|
|
65
|
+
<false/>
|
|
66
|
+
|
|
67
|
+
<key>StandardOutPath</key>
|
|
68
|
+
<string>${path.join(os.homedir(), '.zclean', 'launchd.log')}</string>
|
|
69
|
+
|
|
70
|
+
<key>StandardErrorPath</key>
|
|
71
|
+
<string>${path.join(os.homedir(), '.zclean', 'launchd.log')}</string>
|
|
72
|
+
|
|
73
|
+
<key>EnvironmentVariables</key>
|
|
74
|
+
<dict>
|
|
75
|
+
<key>PATH</key>
|
|
76
|
+
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:${path.join(os.homedir(), '.local', 'bin')}</string>
|
|
77
|
+
</dict>
|
|
78
|
+
</dict>
|
|
79
|
+
</plist>
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Install the launchd agent.
|
|
85
|
+
*
|
|
86
|
+
* @returns {{ installed: boolean, message: string }}
|
|
87
|
+
*/
|
|
88
|
+
function installLaunchd() {
|
|
89
|
+
if (os.platform() !== 'darwin') {
|
|
90
|
+
return { installed: false, message: 'launchd is macOS only.' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Ensure LaunchAgents directory exists
|
|
94
|
+
if (!fs.existsSync(PLIST_DIR)) {
|
|
95
|
+
fs.mkdirSync(PLIST_DIR, { recursive: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const binPath = resolveZcleanBin();
|
|
99
|
+
const plist = generatePlist(binPath);
|
|
100
|
+
|
|
101
|
+
// Unload existing if present
|
|
102
|
+
if (fs.existsSync(PLIST_PATH)) {
|
|
103
|
+
try {
|
|
104
|
+
execSync(`launchctl bootout gui/${process.getuid()} ${PLIST_PATH}`, {
|
|
105
|
+
encoding: 'utf-8',
|
|
106
|
+
timeout: 5000,
|
|
107
|
+
});
|
|
108
|
+
} catch {
|
|
109
|
+
// Might not be loaded
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Write plist
|
|
114
|
+
fs.writeFileSync(PLIST_PATH, plist, 'utf-8');
|
|
115
|
+
|
|
116
|
+
// Load
|
|
117
|
+
try {
|
|
118
|
+
execSync(`launchctl bootstrap gui/${process.getuid()} ${PLIST_PATH}`, {
|
|
119
|
+
encoding: 'utf-8',
|
|
120
|
+
timeout: 5000,
|
|
121
|
+
});
|
|
122
|
+
} catch (err) {
|
|
123
|
+
return {
|
|
124
|
+
installed: true,
|
|
125
|
+
message: `Plist written to ${PLIST_PATH} but launchctl load failed: ${err.message}. Try: launchctl bootstrap gui/$(id -u) ${PLIST_PATH}`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
installed: true,
|
|
131
|
+
message: `Hourly launchd agent installed: ${PLIST_PATH}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Remove the launchd agent.
|
|
137
|
+
*
|
|
138
|
+
* @returns {{ removed: boolean, message: string }}
|
|
139
|
+
*/
|
|
140
|
+
function removeLaunchd() {
|
|
141
|
+
if (os.platform() !== 'darwin') {
|
|
142
|
+
return { removed: false, message: 'launchd is macOS only.' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!fs.existsSync(PLIST_PATH)) {
|
|
146
|
+
return { removed: false, message: 'Plist not found. Already uninstalled.' };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
execSync(`launchctl bootout gui/${process.getuid()} ${PLIST_PATH}`, {
|
|
151
|
+
encoding: 'utf-8',
|
|
152
|
+
timeout: 5000,
|
|
153
|
+
});
|
|
154
|
+
} catch {
|
|
155
|
+
// Might not be loaded
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fs.unlinkSync(PLIST_PATH);
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
removed: true,
|
|
162
|
+
message: `launchd agent removed: ${PLIST_PATH}`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { installLaunchd, removeLaunchd, PLIST_PATH };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const SYSTEMD_USER_DIR = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
9
|
+
const SERVICE_NAME = 'zclean';
|
|
10
|
+
const SERVICE_PATH = path.join(SYSTEMD_USER_DIR, `${SERVICE_NAME}.service`);
|
|
11
|
+
const TIMER_PATH = path.join(SYSTEMD_USER_DIR, `${SERVICE_NAME}.timer`);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve the full path to the zclean binary.
|
|
15
|
+
*/
|
|
16
|
+
function resolveZcleanBin() {
|
|
17
|
+
try {
|
|
18
|
+
const npmBin = execSync('npm bin -g', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
19
|
+
const globalPath = path.join(npmBin, 'zclean');
|
|
20
|
+
if (fs.existsSync(globalPath)) return globalPath;
|
|
21
|
+
} catch { /* ignore */ }
|
|
22
|
+
|
|
23
|
+
const candidates = [
|
|
24
|
+
path.join(os.homedir(), '.local', 'bin', 'zclean'),
|
|
25
|
+
'/usr/local/bin/zclean',
|
|
26
|
+
path.join(os.homedir(), '.local', 'share', 'npm', 'bin', 'zclean'),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const candidate of candidates) {
|
|
30
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return path.join(os.homedir(), '.local', 'bin', 'zclean');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Generate the systemd service unit file.
|
|
38
|
+
*/
|
|
39
|
+
function generateService(binPath) {
|
|
40
|
+
return `[Unit]
|
|
41
|
+
Description=zclean - AI coding tool zombie process cleaner
|
|
42
|
+
Documentation=https://github.com/whynowlab/zclean
|
|
43
|
+
|
|
44
|
+
[Service]
|
|
45
|
+
Type=oneshot
|
|
46
|
+
ExecStart=${binPath} --yes
|
|
47
|
+
Environment=PATH=/usr/local/bin:/usr/bin:/bin:%h/.local/bin
|
|
48
|
+
StandardOutput=append:%h/.zclean/systemd.log
|
|
49
|
+
StandardError=append:%h/.zclean/systemd.log
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Generate the systemd timer unit file.
|
|
55
|
+
*/
|
|
56
|
+
function generateTimer() {
|
|
57
|
+
return `[Unit]
|
|
58
|
+
Description=Run zclean hourly
|
|
59
|
+
Documentation=https://github.com/whynowlab/zclean
|
|
60
|
+
|
|
61
|
+
[Timer]
|
|
62
|
+
OnCalendar=hourly
|
|
63
|
+
Persistent=true
|
|
64
|
+
RandomizedDelaySec=300
|
|
65
|
+
|
|
66
|
+
[Install]
|
|
67
|
+
WantedBy=timers.target
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Install the systemd user timer.
|
|
73
|
+
*
|
|
74
|
+
* @returns {{ installed: boolean, message: string }}
|
|
75
|
+
*/
|
|
76
|
+
function installSystemd() {
|
|
77
|
+
if (os.platform() !== 'linux') {
|
|
78
|
+
return { installed: false, message: 'systemd is Linux only.' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Ensure systemd user directory exists
|
|
82
|
+
if (!fs.existsSync(SYSTEMD_USER_DIR)) {
|
|
83
|
+
fs.mkdirSync(SYSTEMD_USER_DIR, { recursive: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const binPath = resolveZcleanBin();
|
|
87
|
+
|
|
88
|
+
// Write service and timer files
|
|
89
|
+
fs.writeFileSync(SERVICE_PATH, generateService(binPath), 'utf-8');
|
|
90
|
+
fs.writeFileSync(TIMER_PATH, generateTimer(), 'utf-8');
|
|
91
|
+
|
|
92
|
+
// Reload and enable
|
|
93
|
+
const messages = [];
|
|
94
|
+
try {
|
|
95
|
+
execSync('systemctl --user daemon-reload', { encoding: 'utf-8', timeout: 5000 });
|
|
96
|
+
execSync(`systemctl --user enable --now ${SERVICE_NAME}.timer`, {
|
|
97
|
+
encoding: 'utf-8',
|
|
98
|
+
timeout: 5000,
|
|
99
|
+
});
|
|
100
|
+
messages.push(`Timer installed and enabled: ${TIMER_PATH}`);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
messages.push(`Files written but systemctl failed: ${err.message}`);
|
|
103
|
+
messages.push(`Try manually: systemctl --user enable --now ${SERVICE_NAME}.timer`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check linger
|
|
107
|
+
try {
|
|
108
|
+
const lingerDir = `/var/lib/systemd/linger/${os.userInfo().username}`;
|
|
109
|
+
if (!fs.existsSync(lingerDir)) {
|
|
110
|
+
messages.push(`Note: enable linger for timer to run without login: loginctl enable-linger ${os.userInfo().username}`);
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
messages.push(`Note: run 'loginctl enable-linger' to ensure timer runs without login session.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
installed: true,
|
|
118
|
+
message: messages.join('\n'),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remove the systemd user timer and service.
|
|
124
|
+
*
|
|
125
|
+
* @returns {{ removed: boolean, message: string }}
|
|
126
|
+
*/
|
|
127
|
+
function removeSystemd() {
|
|
128
|
+
if (os.platform() !== 'linux') {
|
|
129
|
+
return { removed: false, message: 'systemd is Linux only.' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Disable timer
|
|
133
|
+
try {
|
|
134
|
+
execSync(`systemctl --user disable --now ${SERVICE_NAME}.timer`, {
|
|
135
|
+
encoding: 'utf-8',
|
|
136
|
+
timeout: 5000,
|
|
137
|
+
});
|
|
138
|
+
} catch {
|
|
139
|
+
// Might not be running
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Remove files
|
|
143
|
+
let removed = false;
|
|
144
|
+
for (const filepath of [SERVICE_PATH, TIMER_PATH]) {
|
|
145
|
+
if (fs.existsSync(filepath)) {
|
|
146
|
+
fs.unlinkSync(filepath);
|
|
147
|
+
removed = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Reload daemon
|
|
152
|
+
try {
|
|
153
|
+
execSync('systemctl --user daemon-reload', { encoding: 'utf-8', timeout: 5000 });
|
|
154
|
+
} catch { /* ignore */ }
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
removed,
|
|
158
|
+
message: removed ? 'systemd timer and service removed.' : 'Files not found. Already uninstalled.',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { installSystemd, removeSystemd, SERVICE_PATH, TIMER_PATH };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
|
|
8
|
+
const TASK_NAME = 'zclean-hourly';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the full path to the zclean binary on Windows.
|
|
12
|
+
*/
|
|
13
|
+
function resolveZcleanBin() {
|
|
14
|
+
// Check npm global
|
|
15
|
+
try {
|
|
16
|
+
const npmPrefix = execSync('npm prefix -g', { encoding: 'utf-8', timeout: 5000 }).trim();
|
|
17
|
+
const candidate = path.join(npmPrefix, 'zclean.cmd');
|
|
18
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
19
|
+
const candidate2 = path.join(npmPrefix, 'node_modules', '.bin', 'zclean.cmd');
|
|
20
|
+
if (fs.existsSync(candidate2)) return candidate2;
|
|
21
|
+
} catch { /* ignore */ }
|
|
22
|
+
|
|
23
|
+
// Check AppData local
|
|
24
|
+
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
25
|
+
const npmGlobal = path.join(appData, 'npm', 'zclean.cmd');
|
|
26
|
+
if (fs.existsSync(npmGlobal)) return npmGlobal;
|
|
27
|
+
|
|
28
|
+
return 'npx zclean';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Install a Windows Task Scheduler hourly task.
|
|
33
|
+
*
|
|
34
|
+
* Uses `schtasks /create` with user-scoped hourly task.
|
|
35
|
+
*
|
|
36
|
+
* @returns {{ installed: boolean, message: string }}
|
|
37
|
+
*/
|
|
38
|
+
function installTaskScheduler() {
|
|
39
|
+
if (os.platform() !== 'win32') {
|
|
40
|
+
return { installed: false, message: 'Task Scheduler is Windows only.' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const binPath = resolveZcleanBin();
|
|
44
|
+
|
|
45
|
+
// Build schtasks command
|
|
46
|
+
// /SC HOURLY — run every hour
|
|
47
|
+
// /TN — task name
|
|
48
|
+
// /TR — task to run
|
|
49
|
+
// /F — force overwrite if exists
|
|
50
|
+
const command = [
|
|
51
|
+
'schtasks', '/create',
|
|
52
|
+
'/TN', `"${TASK_NAME}"`,
|
|
53
|
+
'/SC', 'HOURLY',
|
|
54
|
+
'/TR', `"${binPath} --yes"`,
|
|
55
|
+
'/F',
|
|
56
|
+
].join(' ');
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
execSync(command, { encoding: 'utf-8', timeout: 10000 });
|
|
60
|
+
return {
|
|
61
|
+
installed: true,
|
|
62
|
+
message: `Task Scheduler task created: ${TASK_NAME} (hourly)`,
|
|
63
|
+
};
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return {
|
|
66
|
+
installed: false,
|
|
67
|
+
message: `Failed to create scheduled task: ${err.message}\nTry running as administrator.`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Remove the Windows Task Scheduler task.
|
|
74
|
+
*
|
|
75
|
+
* @returns {{ removed: boolean, message: string }}
|
|
76
|
+
*/
|
|
77
|
+
function removeTaskScheduler() {
|
|
78
|
+
if (os.platform() !== 'win32') {
|
|
79
|
+
return { removed: false, message: 'Task Scheduler is Windows only.' };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
execSync(`schtasks /delete /TN "${TASK_NAME}" /F`, {
|
|
84
|
+
encoding: 'utf-8',
|
|
85
|
+
timeout: 10000,
|
|
86
|
+
});
|
|
87
|
+
return {
|
|
88
|
+
removed: true,
|
|
89
|
+
message: `Task Scheduler task removed: ${TASK_NAME}`,
|
|
90
|
+
};
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err.message.includes('does not exist') || err.message.includes('ERROR')) {
|
|
93
|
+
return { removed: false, message: 'Task not found. Already uninstalled.' };
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
removed: false,
|
|
97
|
+
message: `Failed to remove task: ${err.message}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { installTaskScheduler, removeTaskScheduler, TASK_NAME };
|
package/src/killer.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const { appendLog } = require('./config');
|
|
6
|
+
|
|
7
|
+
const platform = os.platform();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Safely kill a list of zombie processes.
|
|
11
|
+
*
|
|
12
|
+
* Before killing each process, re-verifies:
|
|
13
|
+
* 1. PID still exists
|
|
14
|
+
* 2. Process start time matches scan time
|
|
15
|
+
* 3. Command line matches scan
|
|
16
|
+
*
|
|
17
|
+
* Kill sequence:
|
|
18
|
+
* macOS/Linux: SIGTERM -> wait -> SIGKILL
|
|
19
|
+
* Windows: taskkill -> wait -> taskkill /F
|
|
20
|
+
*
|
|
21
|
+
* @param {Array} zombies — array from scanner.scan()
|
|
22
|
+
* @param {object} config — loaded config
|
|
23
|
+
* @returns {{ killed: Array, failed: Array, skipped: Array }}
|
|
24
|
+
*/
|
|
25
|
+
function killZombies(zombies, config) {
|
|
26
|
+
const timeout = (config.sigterm_timeout || 10) * 1000;
|
|
27
|
+
const results = { killed: [], failed: [], skipped: [] };
|
|
28
|
+
|
|
29
|
+
for (const proc of zombies) {
|
|
30
|
+
// Re-verify before killing
|
|
31
|
+
const verification = verifyProcess(proc);
|
|
32
|
+
if (!verification.valid) {
|
|
33
|
+
results.skipped.push({
|
|
34
|
+
...proc,
|
|
35
|
+
skipReason: verification.reason,
|
|
36
|
+
});
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Attempt kill
|
|
41
|
+
const killResult = killProcess(proc.pid, timeout);
|
|
42
|
+
|
|
43
|
+
if (killResult.success) {
|
|
44
|
+
results.killed.push(proc);
|
|
45
|
+
// Log for manual recovery
|
|
46
|
+
appendLog({
|
|
47
|
+
action: 'kill',
|
|
48
|
+
pid: proc.pid,
|
|
49
|
+
name: proc.name,
|
|
50
|
+
cmd: proc.cmd,
|
|
51
|
+
reason: proc.reason,
|
|
52
|
+
memFreed: proc.mem,
|
|
53
|
+
});
|
|
54
|
+
} else {
|
|
55
|
+
results.failed.push({
|
|
56
|
+
...proc,
|
|
57
|
+
error: killResult.error,
|
|
58
|
+
});
|
|
59
|
+
appendLog({
|
|
60
|
+
action: 'kill-failed',
|
|
61
|
+
pid: proc.pid,
|
|
62
|
+
name: proc.name,
|
|
63
|
+
cmd: proc.cmd,
|
|
64
|
+
error: killResult.error,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Log summary
|
|
70
|
+
appendLog({
|
|
71
|
+
action: 'cleanup-summary',
|
|
72
|
+
total: zombies.length,
|
|
73
|
+
killed: results.killed.length,
|
|
74
|
+
failed: results.failed.length,
|
|
75
|
+
skipped: results.skipped.length,
|
|
76
|
+
totalMemFreed: results.killed.reduce((sum, p) => sum + p.mem, 0),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return results;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Re-verify a process before killing it.
|
|
84
|
+
* Ensures we don't kill a recycled PID or wrong process.
|
|
85
|
+
*/
|
|
86
|
+
function verifyProcess(proc) {
|
|
87
|
+
if (platform === 'win32') {
|
|
88
|
+
return verifyProcessWindows(proc);
|
|
89
|
+
}
|
|
90
|
+
return verifyProcessUnix(proc);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function verifyProcessUnix(proc) {
|
|
94
|
+
try {
|
|
95
|
+
// Check if PID still exists and get its command line
|
|
96
|
+
const cmd = execSync(`ps -o command= -p ${proc.pid}`, {
|
|
97
|
+
encoding: 'utf-8',
|
|
98
|
+
timeout: 5000,
|
|
99
|
+
}).trim();
|
|
100
|
+
|
|
101
|
+
if (!cmd) {
|
|
102
|
+
return { valid: false, reason: 'process-gone' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Verify command line matches (at least partially)
|
|
106
|
+
// Use first 50 chars to handle truncation
|
|
107
|
+
const scanPrefix = proc.cmd.substring(0, 50);
|
|
108
|
+
const currentPrefix = cmd.substring(0, 50);
|
|
109
|
+
if (scanPrefix !== currentPrefix) {
|
|
110
|
+
return { valid: false, reason: 'cmd-mismatch' };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Verify start time if available
|
|
114
|
+
if (proc.startTime) {
|
|
115
|
+
try {
|
|
116
|
+
const lstart = execSync(`ps -o lstart= -p ${proc.pid}`, {
|
|
117
|
+
encoding: 'utf-8',
|
|
118
|
+
timeout: 5000,
|
|
119
|
+
}).trim();
|
|
120
|
+
const currentStart = new Date(lstart).toISOString();
|
|
121
|
+
if (currentStart !== proc.startTime) {
|
|
122
|
+
return { valid: false, reason: 'start-time-mismatch' };
|
|
123
|
+
}
|
|
124
|
+
} catch {
|
|
125
|
+
// Can't verify start time — proceed with caution
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { valid: true, reason: 'verified' };
|
|
130
|
+
} catch {
|
|
131
|
+
return { valid: false, reason: 'process-gone' };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function verifyProcessWindows(proc) {
|
|
136
|
+
try {
|
|
137
|
+
const output = execSync(
|
|
138
|
+
`wmic process where ProcessId=${proc.pid} get CommandLine /value`,
|
|
139
|
+
{ encoding: 'utf-8', timeout: 5000 }
|
|
140
|
+
).trim();
|
|
141
|
+
|
|
142
|
+
const match = output.match(/CommandLine=(.+)/);
|
|
143
|
+
if (!match) {
|
|
144
|
+
return { valid: false, reason: 'process-gone' };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const currentCmd = match[1].trim();
|
|
148
|
+
const scanPrefix = proc.cmd.substring(0, 50);
|
|
149
|
+
const currentPrefix = currentCmd.substring(0, 50);
|
|
150
|
+
|
|
151
|
+
if (scanPrefix !== currentPrefix) {
|
|
152
|
+
return { valid: false, reason: 'cmd-mismatch' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { valid: true, reason: 'verified' };
|
|
156
|
+
} catch {
|
|
157
|
+
return { valid: false, reason: 'process-gone' };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Kill a process with graceful shutdown sequence.
|
|
163
|
+
*
|
|
164
|
+
* macOS/Linux: SIGTERM → wait → SIGKILL
|
|
165
|
+
* Windows: taskkill → wait → taskkill /F
|
|
166
|
+
*/
|
|
167
|
+
function killProcess(pid, timeoutMs) {
|
|
168
|
+
if (platform === 'win32') {
|
|
169
|
+
return killProcessWindows(pid, timeoutMs);
|
|
170
|
+
}
|
|
171
|
+
return killProcessUnix(pid, timeoutMs);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function killProcessUnix(pid, timeoutMs) {
|
|
175
|
+
try {
|
|
176
|
+
// Send SIGTERM
|
|
177
|
+
process.kill(pid, 'SIGTERM');
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err.code === 'ESRCH') {
|
|
180
|
+
// Already dead
|
|
181
|
+
return { success: true, method: 'already-dead' };
|
|
182
|
+
}
|
|
183
|
+
return { success: false, error: `SIGTERM failed: ${err.message}` };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Wait for graceful shutdown
|
|
187
|
+
const deadline = Date.now() + timeoutMs;
|
|
188
|
+
while (Date.now() < deadline) {
|
|
189
|
+
try {
|
|
190
|
+
// process.kill(pid, 0) throws if process doesn't exist
|
|
191
|
+
process.kill(pid, 0);
|
|
192
|
+
// Still alive — busy wait in small increments
|
|
193
|
+
const waitUntil = Date.now() + 500;
|
|
194
|
+
while (Date.now() < waitUntil) {
|
|
195
|
+
// Spin wait — we can't use setTimeout synchronously
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// Process is gone
|
|
199
|
+
return { success: true, method: 'sigterm' };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Process survived SIGTERM — send SIGKILL
|
|
204
|
+
try {
|
|
205
|
+
process.kill(pid, 'SIGKILL');
|
|
206
|
+
return { success: true, method: 'sigkill' };
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (err.code === 'ESRCH') {
|
|
209
|
+
return { success: true, method: 'died-during-kill' };
|
|
210
|
+
}
|
|
211
|
+
return { success: false, error: `SIGKILL failed: ${err.message}` };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function killProcessWindows(pid, timeoutMs) {
|
|
216
|
+
try {
|
|
217
|
+
// Graceful kill
|
|
218
|
+
execSync(`taskkill /PID ${pid}`, { encoding: 'utf-8', timeout: 5000 });
|
|
219
|
+
} catch {
|
|
220
|
+
// Might fail — try force kill directly
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Wait
|
|
224
|
+
const deadline = Date.now() + timeoutMs;
|
|
225
|
+
while (Date.now() < deadline) {
|
|
226
|
+
try {
|
|
227
|
+
execSync(`wmic process where ProcessId=${pid} get ProcessId /value`, {
|
|
228
|
+
encoding: 'utf-8',
|
|
229
|
+
timeout: 3000,
|
|
230
|
+
});
|
|
231
|
+
// Still alive — wait
|
|
232
|
+
const waitUntil = Date.now() + 500;
|
|
233
|
+
while (Date.now() < waitUntil) { /* spin */ }
|
|
234
|
+
} catch {
|
|
235
|
+
return { success: true, method: 'taskkill' };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Force kill
|
|
240
|
+
try {
|
|
241
|
+
execSync(`taskkill /F /PID ${pid}`, { encoding: 'utf-8', timeout: 5000 });
|
|
242
|
+
return { success: true, method: 'taskkill-force' };
|
|
243
|
+
} catch (err) {
|
|
244
|
+
return { success: false, error: `Force kill failed: ${err.message}` };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = { killZombies, verifyProcess, killProcess };
|