unitup 0.2.0 → 0.4.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.
- package/CHANGELOG.md +17 -0
- package/index.d.ts +183 -0
- package/package.json +5 -3
- package/src/cli.js +374 -2
- package/src/core/drain-manager.js +70 -0
- package/src/core/generation-manager.js +414 -0
- package/src/core/index.js +8 -0
- package/src/core/ipc.js +236 -0
- package/src/core/lock.js +133 -0
- package/src/core/process-manager.js +313 -0
- package/src/core/readiness.js +244 -0
- package/src/core/router.js +507 -0
- package/src/core/supervisor.js +222 -0
- package/src/deploy/deployment-manager.js +388 -0
- package/src/deploy/index.js +9 -0
- package/src/deploy/rollback-manager.js +187 -0
- package/src/index.js +54 -0
- package/src/platform/macos.js +4 -1
- package/src/platform/windows-host.js +1 -1
- package/src/platform/windows.js +4 -1
- package/src/service/config.js +8 -0
- package/src/service/normalize.js +30 -1
- package/src/unit.js +29 -3
- package/src/utils.js +80 -0
- package/SECURITY.md +0 -21
- package/biome.json +0 -44
- package/docs/index.html +0 -587
- package/tsconfig.json +0 -13
- package/types-test/index.ts +0 -158
package/src/core/ipc.js
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getUnitupDir, sanitizeServiceName } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Returns the IPC endpoint path for a service (Unix domain socket or Windows Named Pipe).
|
|
8
|
+
* @param {string} serviceName
|
|
9
|
+
* @param {string} [ipcDir]
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function getIpcPath(serviceName, ipcDir) {
|
|
13
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
14
|
+
if (process.platform === 'win32') {
|
|
15
|
+
return `\\\\.\\pipe\\unitup-${safeName}`;
|
|
16
|
+
}
|
|
17
|
+
const dir = ipcDir || path.join(getUnitupDir(), 'ipc');
|
|
18
|
+
return path.join(dir, `${safeName}.sock`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lightweight IPC Server hosted by the supervisor.
|
|
23
|
+
*/
|
|
24
|
+
export class IPCServer {
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} options
|
|
27
|
+
* @param {string} options.serviceName
|
|
28
|
+
* @param {function(object, function(object): void): Promise<object>} options.handler
|
|
29
|
+
* @param {string} [options.ipcDir]
|
|
30
|
+
*/
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.serviceName = options.serviceName;
|
|
33
|
+
this.handler = options.handler;
|
|
34
|
+
this.ipcPath = getIpcPath(this.serviceName, options.ipcDir);
|
|
35
|
+
this.server = null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async start() {
|
|
39
|
+
if (process.platform !== 'win32') {
|
|
40
|
+
const dir = path.dirname(this.ipcPath);
|
|
41
|
+
if (!fs.existsSync(dir)) {
|
|
42
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
}
|
|
44
|
+
if (fs.existsSync(this.ipcPath)) {
|
|
45
|
+
try {
|
|
46
|
+
fs.unlinkSync(this.ipcPath);
|
|
47
|
+
} catch {
|
|
48
|
+
// ignore
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const server = net.createServer((socket) => {
|
|
55
|
+
let buffer = '';
|
|
56
|
+
|
|
57
|
+
socket.on('data', async (chunk) => {
|
|
58
|
+
buffer += chunk.toString('utf8');
|
|
59
|
+
const lines = buffer.split('\n');
|
|
60
|
+
buffer = lines.pop(); // preserve unfinished line
|
|
61
|
+
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const trimmed = line.trim();
|
|
64
|
+
if (!trimmed) continue;
|
|
65
|
+
|
|
66
|
+
let message;
|
|
67
|
+
try {
|
|
68
|
+
message = JSON.parse(trimmed);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
socket.write(`${JSON.stringify({ type: 'error', error: 'Invalid JSON' })}\n`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const sendProgress = (progressData) => {
|
|
75
|
+
if (!socket.destroyed) {
|
|
76
|
+
socket.write(`${JSON.stringify({ type: 'progress', ...progressData })}\n`);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const result = await this.handler(message, sendProgress);
|
|
82
|
+
if (!socket.destroyed) {
|
|
83
|
+
socket.write(`${JSON.stringify({ type: 'result', data: result })}\n`);
|
|
84
|
+
}
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (!socket.destroyed) {
|
|
87
|
+
socket.write(
|
|
88
|
+
`${JSON.stringify({
|
|
89
|
+
type: 'error',
|
|
90
|
+
error: err.message,
|
|
91
|
+
name: err.name,
|
|
92
|
+
stack: err.stack
|
|
93
|
+
})}\n`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
server.on('error', reject);
|
|
102
|
+
server.listen(this.ipcPath, () => {
|
|
103
|
+
this.server = server;
|
|
104
|
+
resolve();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async close() {
|
|
110
|
+
if (!this.server) return;
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
this.server.close(() => {
|
|
113
|
+
this.server = null;
|
|
114
|
+
if (process.platform !== 'win32' && fs.existsSync(this.ipcPath)) {
|
|
115
|
+
try {
|
|
116
|
+
fs.unlinkSync(this.ipcPath);
|
|
117
|
+
} catch {
|
|
118
|
+
// ignore
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
resolve();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async stop() {
|
|
127
|
+
return this.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Lightweight IPC Client used by CLI to talk to the running supervisor.
|
|
133
|
+
*/
|
|
134
|
+
export class IPCClient {
|
|
135
|
+
/**
|
|
136
|
+
* @param {string} serviceName
|
|
137
|
+
* @param {string} [ipcDir]
|
|
138
|
+
*/
|
|
139
|
+
constructor(serviceName, ipcDir) {
|
|
140
|
+
this.serviceName = serviceName;
|
|
141
|
+
this.ipcPath = getIpcPath(serviceName, ipcDir);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Checks if the supervisor IPC socket is reachable.
|
|
146
|
+
* @param {number} [timeout=1000]
|
|
147
|
+
* @returns {Promise<boolean>}
|
|
148
|
+
*/
|
|
149
|
+
async isAlive(timeout = 1000) {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
const socket = net.connect(this.ipcPath);
|
|
152
|
+
let connected = false;
|
|
153
|
+
|
|
154
|
+
socket.once('connect', () => {
|
|
155
|
+
connected = true;
|
|
156
|
+
socket.destroy();
|
|
157
|
+
resolve(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
socket.setTimeout(timeout, () => {
|
|
161
|
+
socket.destroy();
|
|
162
|
+
resolve(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
socket.once('error', () => {
|
|
166
|
+
resolve(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Sends a request to the supervisor and streams progress updates.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} payload
|
|
175
|
+
* @param {function(object): void} [onProgress]
|
|
176
|
+
* @param {number} [timeout=120000]
|
|
177
|
+
* @returns {Promise<any>}
|
|
178
|
+
*/
|
|
179
|
+
async send(payload, onProgress, timeout = 120000) {
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
const socket = net.connect(this.ipcPath);
|
|
182
|
+
let buffer = '';
|
|
183
|
+
let timer = null;
|
|
184
|
+
|
|
185
|
+
if (timeout > 0) {
|
|
186
|
+
timer = setTimeout(() => {
|
|
187
|
+
socket.destroy();
|
|
188
|
+
reject(new Error(`IPC request timed out after ${timeout}ms.`));
|
|
189
|
+
}, timeout);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
socket.once('connect', () => {
|
|
193
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
socket.on('data', (chunk) => {
|
|
197
|
+
buffer += chunk.toString('utf8');
|
|
198
|
+
const lines = buffer.split('\n');
|
|
199
|
+
buffer = lines.pop();
|
|
200
|
+
|
|
201
|
+
for (const line of lines) {
|
|
202
|
+
const trimmed = line.trim();
|
|
203
|
+
if (!trimmed) continue;
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const msg = JSON.parse(trimmed);
|
|
207
|
+
if (msg.type === 'progress') {
|
|
208
|
+
if (onProgress) onProgress(msg);
|
|
209
|
+
} else if (msg.type === 'result') {
|
|
210
|
+
if (timer) clearTimeout(timer);
|
|
211
|
+
socket.destroy();
|
|
212
|
+
resolve(msg.data);
|
|
213
|
+
} else if (msg.type === 'error') {
|
|
214
|
+
if (timer) clearTimeout(timer);
|
|
215
|
+
socket.destroy();
|
|
216
|
+
const err = new Error(msg.error);
|
|
217
|
+
err.name = msg.name || 'Error';
|
|
218
|
+
reject(err);
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// Ignore parse errors on individual chunks
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
socket.once('error', (err) => {
|
|
227
|
+
if (timer) clearTimeout(timer);
|
|
228
|
+
reject(err);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
socket.once('close', () => {
|
|
232
|
+
if (timer) clearTimeout(timer);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
package/src/core/lock.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getUnitupDir, sanitizeServiceName } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
export class DeploymentLockError extends Error {
|
|
6
|
+
constructor(message, service, lockedPid) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'DeploymentLockError';
|
|
9
|
+
this.service = service;
|
|
10
|
+
this.lockedPid = lockedPid;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Manages per-service deployment locks to prevent concurrent deployments.
|
|
16
|
+
*/
|
|
17
|
+
export class DeploymentLock {
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} [options]
|
|
20
|
+
* @param {string} [options.lockDir]
|
|
21
|
+
* @param {import('./process-manager.js').ProcessManager} [options.processManager]
|
|
22
|
+
*/
|
|
23
|
+
constructor(options = {}) {
|
|
24
|
+
this.lockDir = options.lockDir || path.join(getUnitupDir(), 'locks');
|
|
25
|
+
this.processManager = options.processManager;
|
|
26
|
+
this._heldLocks = new Set();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getLockFilePath(serviceName) {
|
|
30
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
31
|
+
return path.join(this.lockDir, `${safeName}.deploy.lock`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Acquires the deployment lock for a service.
|
|
36
|
+
* Throws DeploymentLockError if already locked by an active process.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} serviceName
|
|
39
|
+
* @returns {Promise<boolean>}
|
|
40
|
+
*/
|
|
41
|
+
async acquire(serviceName) {
|
|
42
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
43
|
+
|
|
44
|
+
if (this._heldLocks.has(safeName)) {
|
|
45
|
+
throw new DeploymentLockError(
|
|
46
|
+
`Deployment already in progress for service "${safeName}" (PID ${process.pid}).`,
|
|
47
|
+
safeName,
|
|
48
|
+
process.pid
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lockPath = this.getLockFilePath(safeName);
|
|
53
|
+
|
|
54
|
+
if (!fs.existsSync(this.lockDir)) {
|
|
55
|
+
fs.mkdirSync(this.lockDir, { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (fs.existsSync(lockPath)) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = fs.readFileSync(lockPath, 'utf8');
|
|
61
|
+
const info = JSON.parse(raw);
|
|
62
|
+
const lockedPid = Number(info.pid);
|
|
63
|
+
|
|
64
|
+
// Check if the holding process is still alive
|
|
65
|
+
let isAlive = false;
|
|
66
|
+
if (this.processManager) {
|
|
67
|
+
isAlive = this.processManager.isAlive(lockedPid);
|
|
68
|
+
} else if (lockedPid > 0) {
|
|
69
|
+
try {
|
|
70
|
+
process.kill(lockedPid, 0);
|
|
71
|
+
isAlive = true;
|
|
72
|
+
} catch (err) {
|
|
73
|
+
isAlive = err.code === 'EPERM';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (isAlive) {
|
|
78
|
+
throw new DeploymentLockError(
|
|
79
|
+
`Deployment already in progress for service "${safeName}" (PID ${lockedPid}).`,
|
|
80
|
+
safeName,
|
|
81
|
+
lockedPid
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Process is dead: break stale lock
|
|
86
|
+
try {
|
|
87
|
+
fs.unlinkSync(lockPath);
|
|
88
|
+
} catch {
|
|
89
|
+
// ignore
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
if (err instanceof DeploymentLockError) {
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
// Stale or corrupted lock file, overwrite
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const payload = {
|
|
100
|
+
service: safeName,
|
|
101
|
+
pid: process.pid,
|
|
102
|
+
timestamp: new Date().toISOString()
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
fs.writeFileSync(lockPath, JSON.stringify(payload, null, 2), { flag: 'wx' });
|
|
106
|
+
this._heldLocks.add(safeName);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Releases the deployment lock for a service.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} serviceName
|
|
114
|
+
* @returns {Promise<boolean>}
|
|
115
|
+
*/
|
|
116
|
+
async release(serviceName) {
|
|
117
|
+
const safeName = sanitizeServiceName(serviceName);
|
|
118
|
+
const lockPath = this.getLockFilePath(safeName);
|
|
119
|
+
|
|
120
|
+
this._heldLocks.delete(safeName);
|
|
121
|
+
|
|
122
|
+
if (fs.existsSync(lockPath)) {
|
|
123
|
+
try {
|
|
124
|
+
fs.unlinkSync(lockPath);
|
|
125
|
+
return true;
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ProcessManager is responsible ONLY for low-level process lifecycle management.
|
|
7
|
+
* It contains NO deployment sequencing, routing, or drain logic.
|
|
8
|
+
*/
|
|
9
|
+
export class ProcessManager {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.processes = new Map(); // pid -> processEntry
|
|
12
|
+
this.recentExits = new Map(); // pid -> exit info { pid, exitCode, exitSignal, recentStderr }
|
|
13
|
+
this.defaultShutdownTimeout = options.defaultShutdownTimeout || 5000;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Spawns a new process.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} command - Executable path or command name
|
|
20
|
+
* @param {string[]} [args] - Command line arguments
|
|
21
|
+
* @param {object} [options] - Spawn options
|
|
22
|
+
* @param {string} [options.cwd]
|
|
23
|
+
* @param {object} [options.env]
|
|
24
|
+
* @param {string} [options.stdoutPath]
|
|
25
|
+
* @param {string} [options.stderrPath]
|
|
26
|
+
* @returns {object} Process record { pid, child, exitPromise }
|
|
27
|
+
*/
|
|
28
|
+
start(command, args = [], options = {}) {
|
|
29
|
+
const cwd = options.cwd || process.cwd();
|
|
30
|
+
const env = { ...process.env, ...(options.env || {}) };
|
|
31
|
+
|
|
32
|
+
let cmd = command;
|
|
33
|
+
let cmdArgs = Array.isArray(args) ? [...args] : [];
|
|
34
|
+
|
|
35
|
+
// On Windows, if executing a batch script, wrap with cmd.exe
|
|
36
|
+
if (process.platform === 'win32' && /\.(bat|cmd)$/i.test(cmd)) {
|
|
37
|
+
cmdArgs = ['/d', '/s', '/c', `"${cmd}"`, ...cmdArgs];
|
|
38
|
+
cmd = process.env.COMSPEC || 'cmd.exe';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const logStreams = [];
|
|
42
|
+
let stdoutStream = null;
|
|
43
|
+
let stderrStream = null;
|
|
44
|
+
|
|
45
|
+
if (options.stdoutPath) {
|
|
46
|
+
const stdoutDir = path.dirname(options.stdoutPath);
|
|
47
|
+
if (!fs.existsSync(stdoutDir)) {
|
|
48
|
+
fs.mkdirSync(stdoutDir, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
stdoutStream = fs.createWriteStream(options.stdoutPath, { flags: 'a' });
|
|
51
|
+
logStreams.push(stdoutStream);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (options.stderrPath) {
|
|
55
|
+
const stderrDir = path.dirname(options.stderrPath);
|
|
56
|
+
if (!fs.existsSync(stderrDir)) {
|
|
57
|
+
fs.mkdirSync(stderrDir, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
stderrStream = fs.createWriteStream(options.stderrPath, { flags: 'a' });
|
|
60
|
+
logStreams.push(stderrStream);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const child = spawn(cmd, cmdArgs, {
|
|
64
|
+
cwd,
|
|
65
|
+
env,
|
|
66
|
+
stdio: ['ignore', stdoutStream ? 'pipe' : 'inherit', 'pipe'],
|
|
67
|
+
shell: false,
|
|
68
|
+
windowsHide: true
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (stdoutStream && child.stdout) {
|
|
72
|
+
child.stdout.pipe(stdoutStream, { end: false });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let recentStderr = '';
|
|
76
|
+
if (child.stderr) {
|
|
77
|
+
child.stderr.on('data', (chunk) => {
|
|
78
|
+
recentStderr += chunk.toString();
|
|
79
|
+
if (recentStderr.length > 8192) {
|
|
80
|
+
recentStderr = recentStderr.slice(-8192);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
if (stderrStream) {
|
|
84
|
+
child.stderr.pipe(stderrStream, { end: false });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const pid = child.pid;
|
|
89
|
+
let exitResolver;
|
|
90
|
+
const exitPromise = new Promise((resolve) => {
|
|
91
|
+
exitResolver = resolve;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const entry = {
|
|
95
|
+
pid,
|
|
96
|
+
command: cmd,
|
|
97
|
+
args: cmdArgs,
|
|
98
|
+
child,
|
|
99
|
+
exitPromise,
|
|
100
|
+
exitCode: null,
|
|
101
|
+
exitSignal: null,
|
|
102
|
+
exited: false,
|
|
103
|
+
logStreams,
|
|
104
|
+
get recentStderr() {
|
|
105
|
+
return recentStderr;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
child.on('exit', (code, signal) => {
|
|
110
|
+
entry.exitCode = code;
|
|
111
|
+
entry.exitSignal = signal;
|
|
112
|
+
entry.exited = true;
|
|
113
|
+
|
|
114
|
+
// Close log streams
|
|
115
|
+
for (const stream of entry.logStreams) {
|
|
116
|
+
try {
|
|
117
|
+
if (stream && !stream.destroyed) stream.end();
|
|
118
|
+
} catch {
|
|
119
|
+
// ignore
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.processes.delete(pid);
|
|
124
|
+
this.recentExits.set(pid, {
|
|
125
|
+
pid,
|
|
126
|
+
command: cmd,
|
|
127
|
+
exitCode: code,
|
|
128
|
+
exitSignal: signal,
|
|
129
|
+
recentStderr
|
|
130
|
+
});
|
|
131
|
+
if (this.recentExits.size > 50) {
|
|
132
|
+
const first = this.recentExits.keys().next().value;
|
|
133
|
+
this.recentExits.delete(first);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
exitResolver({ code, signal });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
child.on('error', (err) => {
|
|
140
|
+
entry.error = err;
|
|
141
|
+
if (!entry.exited) {
|
|
142
|
+
entry.exited = true;
|
|
143
|
+
this.processes.delete(pid);
|
|
144
|
+
this.recentExits.set(pid, {
|
|
145
|
+
pid,
|
|
146
|
+
command: cmd,
|
|
147
|
+
exitCode: 1,
|
|
148
|
+
exitSignal: null,
|
|
149
|
+
recentStderr: err.message || recentStderr
|
|
150
|
+
});
|
|
151
|
+
exitResolver({ code: 1, signal: null, error: err });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (pid) {
|
|
156
|
+
this.processes.set(pid, entry);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
pid,
|
|
161
|
+
child,
|
|
162
|
+
exitPromise
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Retrieves process info for an active or recently exited process.
|
|
168
|
+
*
|
|
169
|
+
* @param {number|object} processOrPid
|
|
170
|
+
* @returns {object|null}
|
|
171
|
+
*/
|
|
172
|
+
getProcess(processOrPid) {
|
|
173
|
+
const pid = typeof processOrPid === 'number' ? processOrPid : processOrPid?.pid;
|
|
174
|
+
return this.processes.get(pid) || this.recentExits.get(pid) || null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Checks if a process is alive.
|
|
179
|
+
*
|
|
180
|
+
* @param {number|object} processOrPid
|
|
181
|
+
* @returns {boolean}
|
|
182
|
+
*/
|
|
183
|
+
isAlive(processOrPid) {
|
|
184
|
+
const pid = typeof processOrPid === 'number' ? processOrPid : processOrPid?.pid;
|
|
185
|
+
if (!pid || typeof pid !== 'number' || pid <= 0) return false;
|
|
186
|
+
|
|
187
|
+
// Check internal tracked state
|
|
188
|
+
const entry = this.processes.get(pid);
|
|
189
|
+
if (entry && entry.exited) return false;
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
// kill(0) tests whether the process exists without sending a signal
|
|
193
|
+
process.kill(pid, 0);
|
|
194
|
+
return true;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
// ESRCH means process doesn't exist. EPERM means process exists but we lack permission to signal.
|
|
197
|
+
return err.code === 'EPERM';
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Sends a signal to a process.
|
|
203
|
+
*
|
|
204
|
+
* @param {number|object} processOrPid
|
|
205
|
+
* @param {string|number} [signal='SIGTERM']
|
|
206
|
+
* @returns {boolean}
|
|
207
|
+
*/
|
|
208
|
+
signal(processOrPid, signal = 'SIGTERM') {
|
|
209
|
+
const pid = typeof processOrPid === 'number' ? processOrPid : processOrPid?.pid;
|
|
210
|
+
if (!pid || !this.isAlive(pid)) return false;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
process.kill(pid, signal);
|
|
214
|
+
return true;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Waits for a process to exit up to an optional timeout.
|
|
222
|
+
*
|
|
223
|
+
* @param {number|object} processOrPid
|
|
224
|
+
* @param {number} [timeout]
|
|
225
|
+
* @returns {Promise<{ exited: boolean, code?: number, signal?: string }>}
|
|
226
|
+
*/
|
|
227
|
+
async waitForExit(processOrPid, timeout) {
|
|
228
|
+
const pid = typeof processOrPid === 'number' ? processOrPid : processOrPid?.pid;
|
|
229
|
+
if (!pid) return { exited: true };
|
|
230
|
+
|
|
231
|
+
const entry = this.processes.get(pid);
|
|
232
|
+
if (!this.isAlive(pid)) {
|
|
233
|
+
return { exited: true, code: entry?.exitCode ?? null, signal: entry?.exitSignal ?? null };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (entry && entry.exitPromise) {
|
|
237
|
+
if (!timeout || timeout <= 0) {
|
|
238
|
+
const res = await entry.exitPromise;
|
|
239
|
+
return { exited: true, code: res.code, signal: res.signal };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let timer;
|
|
243
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
244
|
+
timer = setTimeout(() => resolve({ exited: false }), timeout);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const res = await Promise.race([
|
|
248
|
+
entry.exitPromise.then((r) => ({ exited: true, code: r.code, signal: r.signal })),
|
|
249
|
+
timeoutPromise
|
|
250
|
+
]);
|
|
251
|
+
clearTimeout(timer);
|
|
252
|
+
return res;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Process is external or untracked PID: poll liveness
|
|
256
|
+
const pollInterval = 50;
|
|
257
|
+
const start = Date.now();
|
|
258
|
+
while (this.isAlive(pid)) {
|
|
259
|
+
if (timeout && Date.now() - start >= timeout) {
|
|
260
|
+
return { exited: false };
|
|
261
|
+
}
|
|
262
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
263
|
+
}
|
|
264
|
+
return { exited: true };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Stops a process gracefully, then force kills if it does not exit within timeout.
|
|
269
|
+
*
|
|
270
|
+
* @param {number|object} processOrPid
|
|
271
|
+
* @param {object} [options]
|
|
272
|
+
* @param {number} [options.timeout]
|
|
273
|
+
* @param {string} [options.signal='SIGTERM']
|
|
274
|
+
* @returns {Promise<boolean>}
|
|
275
|
+
*/
|
|
276
|
+
async stop(processOrPid, options = {}) {
|
|
277
|
+
const pid = typeof processOrPid === 'number' ? processOrPid : processOrPid?.pid;
|
|
278
|
+
if (!pid || !this.isAlive(pid)) return true;
|
|
279
|
+
|
|
280
|
+
const timeout = options.timeout !== undefined ? options.timeout : this.defaultShutdownTimeout;
|
|
281
|
+
const termSignal = options.signal || 'SIGTERM';
|
|
282
|
+
|
|
283
|
+
// 1. Send graceful termination signal
|
|
284
|
+
this.signal(pid, termSignal);
|
|
285
|
+
|
|
286
|
+
// 2. Wait up to timeout
|
|
287
|
+
const exitResult = await this.waitForExit(pid, timeout);
|
|
288
|
+
if (exitResult.exited) {
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 3. Force kill if still alive
|
|
293
|
+
if (this.isAlive(pid)) {
|
|
294
|
+
if (process.platform === 'win32') {
|
|
295
|
+
try {
|
|
296
|
+
await new Promise((resolve) => {
|
|
297
|
+
execFile('taskkill', ['/pid', String(pid), '/T', '/F'], () => resolve());
|
|
298
|
+
});
|
|
299
|
+
} catch {
|
|
300
|
+
// ignore
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
this.signal(pid, 'SIGKILL');
|
|
305
|
+
|
|
306
|
+
// Final short grace period for SIGKILL to take effect
|
|
307
|
+
const finalWait = await this.waitForExit(pid, 2000);
|
|
308
|
+
return finalWait.exited;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
}
|