pid1 0.0.0-dev.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/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +119 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/client-Bdt88RU-.d.mts +217 -0
- package/dist/client-Bdt88RU-.d.mts.map +1 -0
- package/dist/client.d.mts +2 -0
- package/dist/client.mjs +12 -0
- package/dist/client.mjs.map +1 -0
- package/dist/config-BgRb4pSG.mjs +186 -0
- package/dist/config-BgRb4pSG.mjs.map +1 -0
- package/dist/config.d.mts +84 -0
- package/dist/config.d.mts.map +1 -0
- package/dist/config.mjs +3 -0
- package/dist/index.d.mts +36 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +5 -0
- package/dist/process-manager-gO56266Q.mjs +643 -0
- package/dist/process-manager-gO56266Q.mjs.map +1 -0
- package/package.json +52 -0
- package/src/api/client.ts +20 -0
- package/src/api/router.ts +150 -0
- package/src/api/server.ts +66 -0
- package/src/cli.ts +168 -0
- package/src/config.ts +89 -0
- package/src/env.ts +74 -0
- package/src/exec.ts +85 -0
- package/src/index.ts +14 -0
- package/src/logger.ts +155 -0
- package/src/process-manager.ts +632 -0
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { watch, type FSWatcher } from "node:fs";
|
|
3
|
+
import { loadConfigFile } from "./config.ts";
|
|
4
|
+
import { loadEnvForProcess } from "./env.ts";
|
|
5
|
+
import { spawnWithLogging, type SpawnedProcess } from "./exec.ts";
|
|
6
|
+
import { globalLogger } from "./logger.ts";
|
|
7
|
+
import { createServer, type ServerOptions } from "./api/server.ts";
|
|
8
|
+
|
|
9
|
+
export type ProcessManagerOptions = {
|
|
10
|
+
cwd?: string;
|
|
11
|
+
configPath?: string;
|
|
12
|
+
server?: ServerOptions & { enabled?: boolean };
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ProcessStatus = "stopped" | "running" | "errored";
|
|
16
|
+
export type RestartPolicy = "never" | "always" | "on-failure";
|
|
17
|
+
|
|
18
|
+
export type ProcessConfig = {
|
|
19
|
+
name: string;
|
|
20
|
+
command: string;
|
|
21
|
+
args: string[];
|
|
22
|
+
env: Record<string, string>;
|
|
23
|
+
cwd?: string;
|
|
24
|
+
envFile?: string | false;
|
|
25
|
+
restartOnEnvChange?: boolean;
|
|
26
|
+
restartPolicy?: RestartPolicy;
|
|
27
|
+
maxRestarts?: number;
|
|
28
|
+
restartMinDelayMs?: number;
|
|
29
|
+
restartMaxDelayMs?: number;
|
|
30
|
+
restartResetSeconds?: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type ManagedProcess = {
|
|
34
|
+
id: number;
|
|
35
|
+
config: ProcessConfig;
|
|
36
|
+
status: ProcessStatus;
|
|
37
|
+
spawned: SpawnedProcess | null;
|
|
38
|
+
exitCode: number | null;
|
|
39
|
+
restarts: number;
|
|
40
|
+
restartAttempts: number;
|
|
41
|
+
restartTimer: NodeJS.Timeout | null;
|
|
42
|
+
stopRequested: boolean;
|
|
43
|
+
startedAt: Date | null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const DEFAULT_STOP_TIMEOUT_MS = 10000;
|
|
47
|
+
const DEFAULT_RESTART_RESET_SECONDS = 300;
|
|
48
|
+
const DEFAULT_RESTART_MAX = 10;
|
|
49
|
+
const DEFAULT_RESTART_MIN_DELAY_MS = 5000;
|
|
50
|
+
const DEFAULT_RESTART_MAX_DELAY_MS = 60000;
|
|
51
|
+
|
|
52
|
+
export type ProcessInfo = {
|
|
53
|
+
id: number;
|
|
54
|
+
name: string;
|
|
55
|
+
status: ProcessStatus;
|
|
56
|
+
pid: number | undefined;
|
|
57
|
+
exitCode: number | null;
|
|
58
|
+
restarts: number;
|
|
59
|
+
startedAt: Date | null;
|
|
60
|
+
command: string;
|
|
61
|
+
args: string[];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export class ProcessManager {
|
|
65
|
+
private logger = globalLogger.child("pm");
|
|
66
|
+
private cwd: string;
|
|
67
|
+
private configPath?: string;
|
|
68
|
+
private configDir?: string;
|
|
69
|
+
private globalEnvFile?: string | false;
|
|
70
|
+
private processes: Map<string, ManagedProcess>;
|
|
71
|
+
private processIds: Map<number, string>;
|
|
72
|
+
private nextProcessId = 0;
|
|
73
|
+
private signalHandlersSetup = false;
|
|
74
|
+
private serverOptions?: ServerOptions & { enabled?: boolean };
|
|
75
|
+
private httpServer?: ReturnType<ReturnType<typeof createServer>["start"]>;
|
|
76
|
+
private envWatcher: FSWatcher | null = null;
|
|
77
|
+
private envChangeTimers = new Map<string, NodeJS.Timeout>();
|
|
78
|
+
private envRestartTimers = new Map<string, NodeJS.Timeout>();
|
|
79
|
+
private restartOnEnvChangeGlobal = true;
|
|
80
|
+
private restartMinDelayMsGlobal = DEFAULT_RESTART_MIN_DELAY_MS;
|
|
81
|
+
private restartMaxDelayMsGlobal = DEFAULT_RESTART_MAX_DELAY_MS;
|
|
82
|
+
private currentTask: SpawnedProcess | null = null;
|
|
83
|
+
private currentTaskName: string | null = null;
|
|
84
|
+
private taskAbortController: AbortController | null = null;
|
|
85
|
+
|
|
86
|
+
constructor(options: ProcessManagerOptions = {}) {
|
|
87
|
+
this.cwd = options.cwd ?? process.cwd();
|
|
88
|
+
this.configPath = options.configPath;
|
|
89
|
+
this.processes = new Map();
|
|
90
|
+
this.processIds = new Map();
|
|
91
|
+
this.serverOptions = options.server;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async init(): Promise<void> {
|
|
95
|
+
const config = await loadConfigFile(this.configPath, this.cwd);
|
|
96
|
+
|
|
97
|
+
this.configDir = dirname(config.configPath);
|
|
98
|
+
this.globalEnvFile = config.envFile;
|
|
99
|
+
this.restartOnEnvChangeGlobal = config.restartOnEnvChange ?? true;
|
|
100
|
+
this.restartMinDelayMsGlobal =
|
|
101
|
+
config.restartMinDelayMs ?? DEFAULT_RESTART_MIN_DELAY_MS;
|
|
102
|
+
this.restartMaxDelayMsGlobal =
|
|
103
|
+
config.restartMaxDelayMs ?? DEFAULT_RESTART_MAX_DELAY_MS;
|
|
104
|
+
|
|
105
|
+
this.logger.info(
|
|
106
|
+
`Executing ${config.tasks.length} tasks and registering ${config.processes.length} processes`,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
this.setupSignalHandlers();
|
|
110
|
+
this.setupEnvWatchers();
|
|
111
|
+
await this.runTasks(config.tasks);
|
|
112
|
+
|
|
113
|
+
for (const p of config.processes) {
|
|
114
|
+
this.register(p);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
register(config: ProcessConfig): void {
|
|
119
|
+
if (this.processes.has(config.name)) {
|
|
120
|
+
throw new Error(`Process ${config.name} already registered`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const id = this.nextProcessId++;
|
|
124
|
+
const managed: ManagedProcess = {
|
|
125
|
+
id,
|
|
126
|
+
config,
|
|
127
|
+
status: "stopped",
|
|
128
|
+
spawned: null,
|
|
129
|
+
exitCode: null,
|
|
130
|
+
restarts: 0,
|
|
131
|
+
restartAttempts: 0,
|
|
132
|
+
restartTimer: null,
|
|
133
|
+
stopRequested: false,
|
|
134
|
+
startedAt: null,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
this.processes.set(config.name, managed);
|
|
138
|
+
this.processIds.set(id, config.name);
|
|
139
|
+
this.logger.info(`Registered process: ${config.name} (id: ${id})`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
list(): ProcessInfo[] {
|
|
143
|
+
return [...this.processes.values()].map((m) => ({
|
|
144
|
+
id: m.id,
|
|
145
|
+
name: m.config.name,
|
|
146
|
+
status: m.status,
|
|
147
|
+
pid: m.spawned?.proc.pid,
|
|
148
|
+
exitCode: m.exitCode,
|
|
149
|
+
restarts: m.restarts,
|
|
150
|
+
startedAt: m.startedAt,
|
|
151
|
+
command: m.config.command,
|
|
152
|
+
args: m.config.args,
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
start(name: string): void {
|
|
157
|
+
const { name: resolvedName, managed } = this.resolveProcessIdentifier(name);
|
|
158
|
+
|
|
159
|
+
if (managed.status === "running") {
|
|
160
|
+
this.logger.warn(`Process ${resolvedName} is already running`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this.spawnProcess(managed);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
startAll(): void {
|
|
168
|
+
this.setupSignalHandlers();
|
|
169
|
+
|
|
170
|
+
for (const [_, managed] of this.processes) {
|
|
171
|
+
if (managed.status !== "running") {
|
|
172
|
+
this.spawnProcess(managed);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
serve(options?: ServerOptions & { enabled?: boolean }): void {
|
|
178
|
+
const serverOpts = { ...this.serverOptions, ...options };
|
|
179
|
+
if (serverOpts.enabled === false) return;
|
|
180
|
+
const { enabled: _enabled, ...serverOptions } = serverOpts;
|
|
181
|
+
const server = createServer(this, serverOptions);
|
|
182
|
+
this.httpServer = server.start();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async stop(name: string, timeoutMs = DEFAULT_STOP_TIMEOUT_MS): Promise<void> {
|
|
186
|
+
const { name: resolvedName, managed } = this.resolveProcessIdentifier(name);
|
|
187
|
+
|
|
188
|
+
managed.stopRequested = true;
|
|
189
|
+
this.clearRestartTimer(managed);
|
|
190
|
+
|
|
191
|
+
if (managed.status !== "running" || !managed.spawned) {
|
|
192
|
+
this.logger.warn(`Process ${resolvedName} is not running`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const { spawned } = managed;
|
|
197
|
+
|
|
198
|
+
this.logger.info(`Stopping process: ${resolvedName} (SIGTERM)`);
|
|
199
|
+
spawned.kill("SIGTERM");
|
|
200
|
+
|
|
201
|
+
const exitedGracefully = await Promise.race([
|
|
202
|
+
spawned.done.then(() => true).catch(() => true),
|
|
203
|
+
new Promise<false>((resolve) =>
|
|
204
|
+
setTimeout(() => resolve(false), timeoutMs),
|
|
205
|
+
),
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
if (!exitedGracefully && managed.status === "running") {
|
|
209
|
+
this.logger.warn(
|
|
210
|
+
`Process ${resolvedName} did not stop gracefully, sending SIGKILL`,
|
|
211
|
+
);
|
|
212
|
+
spawned.kill("SIGKILL");
|
|
213
|
+
await spawned.done.catch(() => {});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async stopAll(): Promise<void> {
|
|
218
|
+
this.logger.info("Stopping all processes...");
|
|
219
|
+
|
|
220
|
+
for (const managed of this.processes.values()) {
|
|
221
|
+
managed.stopRequested = true;
|
|
222
|
+
this.clearRestartTimer(managed);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const stopPromises = [...this.processes.entries()]
|
|
226
|
+
.filter(([, m]) => m.status === "running")
|
|
227
|
+
.map(([name]) => this.stop(name));
|
|
228
|
+
|
|
229
|
+
await Promise.allSettled(stopPromises);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async restart(name: string): Promise<void> {
|
|
233
|
+
const { name: resolvedName, managed } = this.resolveProcessIdentifier(name);
|
|
234
|
+
|
|
235
|
+
this.logger.info(`Restarting process: ${resolvedName}`);
|
|
236
|
+
|
|
237
|
+
if (managed.status === "running") {
|
|
238
|
+
await this.stop(resolvedName);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
managed.restarts++;
|
|
242
|
+
this.spawnProcess(managed);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async restartAll(): Promise<void> {
|
|
246
|
+
this.logger.info("Restarting all processes...");
|
|
247
|
+
|
|
248
|
+
for (const [name] of this.processes) {
|
|
249
|
+
await this.restart(name);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async delete(name: string): Promise<void> {
|
|
254
|
+
const { name: resolvedName, managed } = this.resolveProcessIdentifier(name);
|
|
255
|
+
|
|
256
|
+
managed.stopRequested = true;
|
|
257
|
+
this.clearRestartTimer(managed);
|
|
258
|
+
|
|
259
|
+
if (managed.status === "running") {
|
|
260
|
+
await this.stop(resolvedName);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
this.processes.delete(resolvedName);
|
|
264
|
+
this.processIds.delete(managed.id);
|
|
265
|
+
this.logger.info(`Deleted process: ${resolvedName}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async deleteAll(): Promise<void> {
|
|
269
|
+
await this.stopAll();
|
|
270
|
+
this.processes.clear();
|
|
271
|
+
this.logger.info("Deleted all processes");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
get(name: string): ProcessInfo | undefined {
|
|
275
|
+
let resolved: { name: string; managed: ManagedProcess };
|
|
276
|
+
try {
|
|
277
|
+
resolved = this.resolveProcessIdentifier(name);
|
|
278
|
+
} catch {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
id: resolved.managed.id,
|
|
284
|
+
name: resolved.managed.config.name,
|
|
285
|
+
status: resolved.managed.status,
|
|
286
|
+
pid: resolved.managed.spawned?.proc.pid,
|
|
287
|
+
exitCode: resolved.managed.exitCode,
|
|
288
|
+
restarts: resolved.managed.restarts,
|
|
289
|
+
startedAt: resolved.managed.startedAt,
|
|
290
|
+
command: resolved.managed.config.command,
|
|
291
|
+
args: resolved.managed.config.args,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async shutdown(): Promise<void> {
|
|
296
|
+
await this.stopActiveTask();
|
|
297
|
+
await this.stopAll();
|
|
298
|
+
this.closeEnvWatcher();
|
|
299
|
+
if (this.httpServer) {
|
|
300
|
+
this.httpServer.close();
|
|
301
|
+
this.logger.info("HTTP server closed");
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private spawnProcess(managed: ManagedProcess): void {
|
|
306
|
+
const { config } = managed;
|
|
307
|
+
|
|
308
|
+
managed.stopRequested = false;
|
|
309
|
+
this.clearRestartTimer(managed);
|
|
310
|
+
|
|
311
|
+
const env = loadEnvForProcess({
|
|
312
|
+
configDir: this.configDir ?? this.cwd,
|
|
313
|
+
processName: config.name,
|
|
314
|
+
globalEnvFile: this.globalEnvFile,
|
|
315
|
+
processEnvFile: config.envFile,
|
|
316
|
+
configEnv: config.env,
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const spawned = spawnWithLogging({
|
|
320
|
+
cwd: config.cwd ?? this.cwd,
|
|
321
|
+
logFile: join(this.cwd, "logs", "processes", `${config.name}.log`),
|
|
322
|
+
type: "process",
|
|
323
|
+
name: config.name,
|
|
324
|
+
command: config.command,
|
|
325
|
+
args: config.args,
|
|
326
|
+
env,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
managed.spawned = spawned;
|
|
330
|
+
managed.status = "running";
|
|
331
|
+
managed.startedAt = new Date();
|
|
332
|
+
managed.exitCode = null;
|
|
333
|
+
|
|
334
|
+
this.logger.info(
|
|
335
|
+
`Started process: ${config.name} (pid: ${spawned.proc.pid})`,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const handleExit = (exitCode: number | null, error?: unknown) => {
|
|
339
|
+
managed.exitCode = exitCode;
|
|
340
|
+
managed.status =
|
|
341
|
+
exitCode === null || exitCode === undefined || exitCode === 0
|
|
342
|
+
? "stopped"
|
|
343
|
+
: "errored";
|
|
344
|
+
const now = Date.now();
|
|
345
|
+
const startedAt = managed.startedAt?.getTime() ?? now;
|
|
346
|
+
const uptimeMs = Math.max(0, now - startedAt);
|
|
347
|
+
const resetMs =
|
|
348
|
+
(config.restartResetSeconds ?? DEFAULT_RESTART_RESET_SECONDS) * 1000;
|
|
349
|
+
|
|
350
|
+
if (uptimeMs >= resetMs) {
|
|
351
|
+
managed.restartAttempts = 0;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (exitCode === null || exitCode === undefined) {
|
|
355
|
+
this.logger.info(`Process ${config.name} stopped`);
|
|
356
|
+
} else if (exitCode !== 0) {
|
|
357
|
+
this.logger.info(`Process ${config.name} exited with code ${exitCode}`);
|
|
358
|
+
} else {
|
|
359
|
+
this.logger.info(`Process ${config.name} exited successfully`);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (error) {
|
|
363
|
+
this.logger.error(`Process ${config.name} failed with error: ${error}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const policy = config.restartPolicy ?? "on-failure";
|
|
367
|
+
const isFailure =
|
|
368
|
+
exitCode === null || exitCode === undefined || exitCode !== 0;
|
|
369
|
+
const shouldRestart =
|
|
370
|
+
!managed.stopRequested &&
|
|
371
|
+
(policy === "always" || (policy === "on-failure" && isFailure));
|
|
372
|
+
|
|
373
|
+
if (!shouldRestart) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const maxRestarts = config.maxRestarts ?? DEFAULT_RESTART_MAX;
|
|
378
|
+
if (managed.restartAttempts >= maxRestarts) {
|
|
379
|
+
managed.status = "errored";
|
|
380
|
+
this.logger.warn(
|
|
381
|
+
`Process ${config.name} reached restart limit (${maxRestarts}), not restarting`,
|
|
382
|
+
);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
managed.restartAttempts += 1;
|
|
387
|
+
managed.restarts += 1;
|
|
388
|
+
const minDelay = config.restartMinDelayMs ?? this.restartMinDelayMsGlobal;
|
|
389
|
+
const maxDelay = config.restartMaxDelayMs ?? this.restartMaxDelayMsGlobal;
|
|
390
|
+
const delay = Math.min(
|
|
391
|
+
maxDelay,
|
|
392
|
+
minDelay * Math.pow(2, managed.restartAttempts - 1),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
this.logger.warn(
|
|
396
|
+
`Restarting process ${config.name} in ${delay}ms (attempt ${managed.restartAttempts}/${maxRestarts})`,
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
managed.restartTimer = setTimeout(() => {
|
|
400
|
+
managed.restartTimer = null;
|
|
401
|
+
if (!managed.stopRequested) {
|
|
402
|
+
this.spawnProcess(managed);
|
|
403
|
+
}
|
|
404
|
+
}, delay);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
spawned.done
|
|
408
|
+
.then((exitCode) => handleExit(exitCode))
|
|
409
|
+
.catch((error) => {
|
|
410
|
+
const exitCode = spawned.proc.exitCode ?? 1;
|
|
411
|
+
handleExit(exitCode, error);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private async runTasks(tasks: Array<ProcessConfig>): Promise<void> {
|
|
416
|
+
for (const task of tasks) {
|
|
417
|
+
const env = loadEnvForProcess({
|
|
418
|
+
configDir: this.configDir ?? this.cwd,
|
|
419
|
+
processName: task.name,
|
|
420
|
+
globalEnvFile: this.globalEnvFile,
|
|
421
|
+
processEnvFile: task.envFile,
|
|
422
|
+
configEnv: task.env,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const taskAbortController = new AbortController();
|
|
426
|
+
this.taskAbortController = taskAbortController;
|
|
427
|
+
|
|
428
|
+
const spawned = spawnWithLogging({
|
|
429
|
+
cwd: task.cwd ?? this.cwd,
|
|
430
|
+
logFile: join(this.cwd, "logs", "tasks", `${task.name}.log`),
|
|
431
|
+
type: "task",
|
|
432
|
+
name: task.name,
|
|
433
|
+
command: task.command,
|
|
434
|
+
args: task.args,
|
|
435
|
+
env,
|
|
436
|
+
abortSignal: taskAbortController.signal,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
this.currentTask = spawned;
|
|
440
|
+
this.currentTaskName = task.name;
|
|
441
|
+
|
|
442
|
+
let exitCode: number | null;
|
|
443
|
+
try {
|
|
444
|
+
exitCode = await spawned.done;
|
|
445
|
+
} finally {
|
|
446
|
+
this.currentTask = null;
|
|
447
|
+
this.currentTaskName = null;
|
|
448
|
+
this.taskAbortController = null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (exitCode !== 0) {
|
|
452
|
+
this.logger.error(
|
|
453
|
+
`Task ${task.name} failed with exit code ${exitCode}`,
|
|
454
|
+
);
|
|
455
|
+
throw new Error(`Task ${task.name} failed`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (tasks.length > 0) {
|
|
460
|
+
this.logger.info("All tasks completed");
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private clearRestartTimer(managed: ManagedProcess): void {
|
|
465
|
+
if (managed.restartTimer) {
|
|
466
|
+
clearTimeout(managed.restartTimer);
|
|
467
|
+
managed.restartTimer = null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private async stopActiveTask(
|
|
472
|
+
timeoutMs = DEFAULT_STOP_TIMEOUT_MS,
|
|
473
|
+
): Promise<void> {
|
|
474
|
+
if (!this.currentTask) return;
|
|
475
|
+
|
|
476
|
+
const taskName = this.currentTaskName ?? "unknown";
|
|
477
|
+
this.logger.info(`Stopping task: ${taskName} (SIGTERM)`);
|
|
478
|
+
this.currentTask.kill("SIGTERM");
|
|
479
|
+
|
|
480
|
+
const exitedGracefully = await Promise.race([
|
|
481
|
+
this.currentTask.done.then(() => true).catch(() => true),
|
|
482
|
+
new Promise<false>((resolve) =>
|
|
483
|
+
setTimeout(() => resolve(false), timeoutMs),
|
|
484
|
+
),
|
|
485
|
+
]);
|
|
486
|
+
|
|
487
|
+
if (!exitedGracefully) {
|
|
488
|
+
this.logger.warn(
|
|
489
|
+
`Task ${taskName} did not stop gracefully, sending SIGKILL`,
|
|
490
|
+
);
|
|
491
|
+
this.currentTask.kill("SIGKILL");
|
|
492
|
+
await this.currentTask.done.catch(() => {});
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private abortActiveTask(signal: NodeJS.Signals): void {
|
|
497
|
+
if (this.taskAbortController && !this.taskAbortController.signal.aborted) {
|
|
498
|
+
this.taskAbortController.abort();
|
|
499
|
+
}
|
|
500
|
+
if (this.currentTask) {
|
|
501
|
+
this.currentTask.kill(signal);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
private setupSignalHandlers(): void {
|
|
506
|
+
if (this.signalHandlersSetup) return;
|
|
507
|
+
this.signalHandlersSetup = true;
|
|
508
|
+
|
|
509
|
+
const signals = ["SIGINT", "SIGTERM"] as const;
|
|
510
|
+
for (const signal of signals) {
|
|
511
|
+
process.on(signal, () => {
|
|
512
|
+
process.stdout.write("\r\x1b[K");
|
|
513
|
+
this.logger.info(`Received ${signal}, shutting down...`);
|
|
514
|
+
this.abortActiveTask(signal);
|
|
515
|
+
this.shutdown().then(() => process.exit(0));
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private setupEnvWatchers(): void {
|
|
521
|
+
if (this.envWatcher) return;
|
|
522
|
+
if (!this.restartOnEnvChangeGlobal) return;
|
|
523
|
+
|
|
524
|
+
const watchDir = this.configDir ?? this.cwd;
|
|
525
|
+
this.envWatcher = watch(watchDir, (event, filename) => {
|
|
526
|
+
if (!filename) return;
|
|
527
|
+
const envFile = filename.toString();
|
|
528
|
+
if (!envFile.startsWith(".env")) return;
|
|
529
|
+
if (event !== "change" && event !== "rename") return;
|
|
530
|
+
|
|
531
|
+
if (envFile === ".env") {
|
|
532
|
+
this.scheduleEnvRestart("global", () => {
|
|
533
|
+
this.logger.info("Detected .env change, scheduling restarts");
|
|
534
|
+
for (const managed of this.processes.values()) {
|
|
535
|
+
this.scheduleEnvRestartForProcess(managed);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (!envFile.startsWith(".env.")) return;
|
|
542
|
+
const processName = envFile.slice(".env.".length);
|
|
543
|
+
if (!processName) return;
|
|
544
|
+
|
|
545
|
+
this.scheduleEnvRestart(processName, () => {
|
|
546
|
+
const managed = this.processes.get(processName);
|
|
547
|
+
if (!managed) return;
|
|
548
|
+
this.logger.info(
|
|
549
|
+
`Detected ${envFile} change, scheduling restart for ${processName}`,
|
|
550
|
+
);
|
|
551
|
+
this.scheduleEnvRestartForProcess(managed);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private closeEnvWatcher(): void {
|
|
557
|
+
if (this.envWatcher) {
|
|
558
|
+
this.envWatcher.close();
|
|
559
|
+
this.envWatcher = null;
|
|
560
|
+
}
|
|
561
|
+
for (const timer of this.envChangeTimers.values()) {
|
|
562
|
+
clearTimeout(timer);
|
|
563
|
+
}
|
|
564
|
+
this.envChangeTimers.clear();
|
|
565
|
+
for (const timer of this.envRestartTimers.values()) {
|
|
566
|
+
clearTimeout(timer);
|
|
567
|
+
}
|
|
568
|
+
this.envRestartTimers.clear();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private scheduleEnvRestart(key: string, restart: () => void): void {
|
|
572
|
+
const existing = this.envChangeTimers.get(key);
|
|
573
|
+
if (existing) clearTimeout(existing);
|
|
574
|
+
const timer = setTimeout(() => {
|
|
575
|
+
this.envChangeTimers.delete(key);
|
|
576
|
+
restart();
|
|
577
|
+
}, 250);
|
|
578
|
+
this.envChangeTimers.set(key, timer);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private scheduleEnvRestartForProcess(managed: ManagedProcess): void {
|
|
582
|
+
if (!this.restartOnEnvChangeGlobal) return;
|
|
583
|
+
if (managed.config.restartOnEnvChange === false) return;
|
|
584
|
+
if (managed.status !== "running") return;
|
|
585
|
+
const name = managed.config.name;
|
|
586
|
+
const existing = this.envRestartTimers.get(name);
|
|
587
|
+
if (existing) clearTimeout(existing);
|
|
588
|
+
|
|
589
|
+
const delayMs =
|
|
590
|
+
managed.config.restartMinDelayMs ?? this.restartMinDelayMsGlobal;
|
|
591
|
+
|
|
592
|
+
const timer = setTimeout(
|
|
593
|
+
() => {
|
|
594
|
+
this.envRestartTimers.delete(name);
|
|
595
|
+
this.restart(name).catch((error) => {
|
|
596
|
+
this.logger.error(
|
|
597
|
+
`Failed to restart ${name} after env change: ${error}`,
|
|
598
|
+
);
|
|
599
|
+
});
|
|
600
|
+
},
|
|
601
|
+
Math.max(0, delayMs),
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
this.envRestartTimers.set(name, timer);
|
|
605
|
+
this.logger.info(
|
|
606
|
+
`Restarting ${name} after env change in ${Math.max(0, delayMs)}ms`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private resolveProcessIdentifier(nameOrId: string): {
|
|
611
|
+
name: string;
|
|
612
|
+
managed: ManagedProcess;
|
|
613
|
+
} {
|
|
614
|
+
const byName = this.processes.get(nameOrId);
|
|
615
|
+
if (byName) {
|
|
616
|
+
return { name: nameOrId, managed: byName };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (/^\d+$/.test(nameOrId)) {
|
|
620
|
+
const id = Number(nameOrId);
|
|
621
|
+
const name = this.processIds.get(id);
|
|
622
|
+
if (name) {
|
|
623
|
+
const managed = this.processes.get(name);
|
|
624
|
+
if (managed) {
|
|
625
|
+
return { name, managed };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
throw new Error(`Process ${nameOrId} not found`);
|
|
631
|
+
}
|
|
632
|
+
}
|